Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761cc2371a | ||
|
|
3752b3ec31 | ||
|
|
c2ed31dc4c | ||
|
|
d9e44350d2 | ||
|
|
0f4de5b98e | ||
|
|
a971b5b70e | ||
|
|
6beaa89be2 | ||
|
|
40b02f479b | ||
|
|
afda1cd846 | ||
|
|
a7c6644691 | ||
|
|
3a88ad0855 | ||
|
|
61cc3f6040 | ||
|
|
3239e8b97e | ||
|
|
48d5bd1aee | ||
|
|
5203f4f047 | ||
|
|
9dfa7bf931 | ||
|
|
a072e29016 | ||
|
|
ba68fcfbfc | ||
|
|
02a68e1738 | ||
|
|
c9fac95760 | ||
|
|
b631002c58 | ||
|
|
041776fc9f | ||
|
|
78155ed5b8 | ||
|
|
51c18f603a | ||
|
|
66add30d65 | ||
|
|
b3f6edddfe | ||
|
|
a046fd30fe | ||
|
|
1e5f430ac5 | ||
|
|
f3040e3762 | ||
|
|
db54eefeae | ||
|
|
37954523ae | ||
|
|
c391e1dbf2 | ||
|
|
20ec49b2e7 | ||
|
|
8715130c8b | ||
|
|
6689be0351 | ||
|
|
c2c8a07b75 | ||
|
|
b83eafd794 | ||
|
|
e93fc20294 | ||
|
|
9e2a7c41a0 | ||
|
|
a4ba47a439 | ||
|
|
93e8c00607 | ||
|
|
1e0063258a | ||
|
|
15cef94ba6 | ||
|
|
431dc43e43 | ||
|
|
e750891912 | ||
|
|
fb2089aaf5 | ||
|
|
88bd4812e0 | ||
|
|
6f9e2d5c16 | ||
|
|
d084af162f | ||
|
|
e01b9a20d9 | ||
|
|
bd9b8a2db3 | ||
|
|
c67c25128c | ||
|
|
b9e5536794 | ||
|
|
15e1f6327f | ||
|
|
57a4d9c6e1 | ||
|
|
e87abf02a8 | ||
|
|
e4c2b3db94 | ||
|
|
2ec817e76a | ||
|
|
9e39a6d124 | ||
|
|
ec8dc0c2da | ||
|
|
22e06db73d | ||
|
|
aebeb66268 |
No files matched your search
Generated
+1
@@ -4580,6 +4580,7 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"windows",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -148,6 +148,9 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/vectorcraft
|
||||
cd vectorcraft
|
||||
|
||||
cargo run --release -p vectorcraft # desktop app
|
||||
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft
|
||||
cargo run --release -p vectorcraft -- --control 7979 # + JSON control channel
|
||||
@@ -217,7 +220,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
+6
-5
@@ -6,7 +6,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
@@ -52,7 +52,7 @@ everything is measured from our code against Illustrator's public documentation.
|
||||
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization (12 key languages) | 63% (measured) | 60–100 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
|
||||
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
|
||||
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
|
||||
@@ -107,7 +107,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | none | 0% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
@@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|---|---|
|
||||
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
|
||||
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
|
||||
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
|
||||
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
|
||||
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
|
||||
@@ -136,7 +137,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
|
||||
@@ -26,6 +26,12 @@ vectorcraft-text = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# `perf` on Windows (src/perf.rs) reads how busy the processors are (GetSystemTimes) and this
|
||||
# process's working set (GetProcessMemoryInfo) through winsafe's safe wrappers over Win32 (MIT),
|
||||
# which the desktop app's src/gpu.rs also uses.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
|
||||
|
||||
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
|
||||
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
//! vectorcraft-cli perf [--paths 50000] [--deep]
|
||||
//! ```
|
||||
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
|
||||
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
|
||||
@@ -104,9 +104,11 @@ USAGE:
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
vectorcraft-cli perf [--paths N] [--deep]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
|
||||
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
|
||||
budget is exceeded, unless the report says the machine is busy. --deep also times work with
|
||||
no budget yet and prints memory use (the working set on Windows).
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! `vectorcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
|
||||
//!
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
|
||||
//! average above the core count) they are noise, and the report says so.
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
|
||||
//! average above three quarters of the cores, or on Windows more than three quarters of the
|
||||
//! cores busy over a second) they are noise, and the report says so.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -61,6 +62,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn load_average() -> Option<f64> {
|
||||
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
|
||||
let text = match out {
|
||||
@@ -70,6 +72,27 @@ fn load_average() -> Option<f64> {
|
||||
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
|
||||
}
|
||||
|
||||
/// The cores busy on average over one second (Windows), if available: the share of the
|
||||
/// processors' time that `GetSystemTimes` reports as kernel or user time but not as idle time,
|
||||
/// times `cores`. A load average also counts the threads waiting for a core.
|
||||
#[cfg(windows)]
|
||||
fn busy_cores(cores: usize) -> Option<f64> {
|
||||
let times = || winsafe::GetSystemTimes().ok().map(|(idle, kernel, user)| [idle, kernel, user].map(u64::from));
|
||||
let before = times()?;
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
busy_share(before, times()?).map(|share| share * cores as f64)
|
||||
}
|
||||
|
||||
/// The share of the processors' time between two `[idle, kernel, user]` readings of
|
||||
/// `GetSystemTimes` that was busy (kernel time includes idle time). It is None when no time
|
||||
/// passed, a time went back, or the idle time exceeds the kernel and user time.
|
||||
#[cfg(any(windows, test))]
|
||||
fn busy_share([idle0, kernel0, user0]: [u64; 3], [idle1, kernel1, user1]: [u64; 3]) -> Option<f64> {
|
||||
let total = kernel1.checked_sub(kernel0)?.checked_add(user1.checked_sub(user0)?)?;
|
||||
let busy = total.checked_sub(idle1.checked_sub(idle0)?)?;
|
||||
(total > 0).then(|| busy as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
|
||||
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
|
||||
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
@@ -114,12 +137,21 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
}
|
||||
|
||||
/// Resident memory of this process in MB (`ps`), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let pid = std::process::id().to_string();
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
|
||||
}
|
||||
|
||||
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
|
||||
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
|
||||
#[cfg(windows)]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
|
||||
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
|
||||
fn deep(n: usize) -> Result<(), String> {
|
||||
@@ -243,8 +275,10 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
for (name, ms) in &rows {
|
||||
outln!(" {name:<52} {ms:>10.2} ms");
|
||||
}
|
||||
// `rss_mb` reads the working set on Windows.
|
||||
let memory = if cfg!(windows) { "working set" } else { "memory" };
|
||||
for (what, mb) in &mem {
|
||||
outln!(" memory {what:<45} {mb:>8.0} MB");
|
||||
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -261,8 +295,12 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
|
||||
#[cfg(not(windows))]
|
||||
let (load, label) = (load_average(), "load average");
|
||||
#[cfg(windows)]
|
||||
let (load, label) = (busy_cores(cores), "cores busy");
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, {label} {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
@@ -358,3 +396,38 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn busy_share_leaves_out_idle_time() {
|
||||
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
|
||||
// 10,000,000 of them idle.
|
||||
assert_eq!(busy_share([7, 100, 50], [10_000_007, 25_000_100, 15_000_050]), Some(0.75));
|
||||
// There is no share when no time passed, a time went back, or the idle time exceeds the
|
||||
// kernel and user time.
|
||||
assert_eq!(busy_share([7, 100, 50], [7, 100, 50]), None);
|
||||
assert_eq!(busy_share([7, 100, 50], [8, 99, 60]), None);
|
||||
assert_eq!(busy_share([0, 0, 0], [30, 10, 10]), None);
|
||||
}
|
||||
|
||||
/// On Windows `busy_cores` gives a number between 0 and the cores.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_how_many_cores_are_busy() {
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let busy = busy_cores(cores);
|
||||
assert!(busy.is_some_and(|b| (0.0..=cores as f64).contains(&b)), "{busy:?} of {cores}");
|
||||
}
|
||||
|
||||
/// On Windows `rss_mb` gives this process's working set: more than 0 MB and less than
|
||||
/// 10,000 MB.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_the_working_set() {
|
||||
let mb = rss_mb();
|
||||
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,12 @@ fn help_lists_readable_formats() {
|
||||
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_perf_deep() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
|
||||
}
|
||||
|
||||
/// `info` reports the import warnings: an EPS read only up to an error names it.
|
||||
#[test]
|
||||
fn info_reports_why_an_eps_read_partly() {
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
//! (`Services::system_clipboard`).
|
||||
//!
|
||||
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
|
||||
//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! Paste reads each of them and EPS, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
|
||||
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF
|
||||
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps, and on macOS the pasteboard's SVG, PDF
|
||||
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
|
||||
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
|
||||
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol.
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
|
||||
//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
use vectorcraft_ui_egui::SystemClipboard;
|
||||
|
||||
/// This platform's system clipboard.
|
||||
@@ -27,6 +28,34 @@ pub fn system_clipboard() -> Box<dyn SystemClipboard> {
|
||||
cb
|
||||
}
|
||||
|
||||
/// The registered Windows clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn windows_formats(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
EPS => &["Encapsulated PostScript", "application/postscript"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard types Paste reads for `mime` (`image/*`: any bitmap), best first, each with
|
||||
/// the MIME type of its data. They are the system's uniform type identifiers (the values of
|
||||
/// `NSPasteboardTypePDF`, `NSPasteboardTypePNG` and `NSPasteboardTypeTIFF` among them) and the
|
||||
/// older name some apps still give PDF. Text is read through arboard.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn pasteboard_types(mime: &str) -> &'static [(&'static str, &'static str)] {
|
||||
match mime {
|
||||
SVG => &[("public.svg-image", SVG)],
|
||||
PDF => &[("com.adobe.pdf", PDF), ("Apple PDF pasteboard type", PDF)], // brand-ok: the system's type identifier
|
||||
EPS => &[("com.adobe.encapsulated-postscript", EPS)], // brand-ok: the system's type identifier
|
||||
BITMAP => &[("public.png", PNG), ("public.tiff", "image/tiff")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The text flavour among `flavours`.
|
||||
fn text_of(flavours: &[Flavour]) -> Option<String> {
|
||||
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
|
||||
@@ -127,27 +156,15 @@ mod win {
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The registered clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
fn names(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
|
||||
/// other or a `CF_BITMAP` is: the system converts between them.
|
||||
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
|
||||
|
||||
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
|
||||
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
windows_formats(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
@@ -350,27 +367,30 @@ mod portable {
|
||||
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
|
||||
return Some(f);
|
||||
}
|
||||
mimes.iter().find_map(|m| match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::bitmap() {
|
||||
return Some(f);
|
||||
mimes.iter().find_map(|m| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::read(m) {
|
||||
return Some(f);
|
||||
}
|
||||
match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba =
|
||||
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
_ => None,
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
|
||||
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
|
||||
/// menu item waits for text.
|
||||
/// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
|
||||
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
|
||||
/// take it, the menu item waits for text.
|
||||
fn has(&mut self, mimes: &[&'static str]) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
|
||||
if super::mac::has(mimes) {
|
||||
return true;
|
||||
}
|
||||
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
|
||||
@@ -378,7 +398,8 @@ mod portable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
|
||||
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
|
||||
/// pictures TIFF only, decoded).
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac {
|
||||
use objc2::rc::autoreleasepool;
|
||||
@@ -387,24 +408,20 @@ mod mac {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
|
||||
/// `NSPasteboardTypeTIFF`), with their MIME types.
|
||||
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
|
||||
|
||||
/// Does the pasteboard hold a bitmap? Asks for its types only.
|
||||
pub fn has_bitmap() -> bool {
|
||||
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
|
||||
pub fn has(mimes: &[&'static str]) -> bool {
|
||||
autoreleasepool(|_| {
|
||||
let types = TYPES.map(|(t, _)| NSString::from_str(t));
|
||||
let types: Vec<_> = mimes.iter().flat_map(|m| pasteboard_types(m)).map(|(t, _)| NSString::from_str(t)).collect();
|
||||
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
|
||||
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
!types.is_empty() && NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// The pasteboard's bitmap, PNG before TIFF.
|
||||
pub fn bitmap() -> Option<Flavour> {
|
||||
/// The pasteboard's data for `mime`, the first of its types held ([`pasteboard_types`]).
|
||||
pub fn read(mime: &str) -> Option<Flavour> {
|
||||
autoreleasepool(|_| {
|
||||
let pasteboard = NSPasteboard::generalPasteboard();
|
||||
TYPES.into_iter().find_map(|(t, mime)| {
|
||||
pasteboard_types(mime).iter().find_map(|&(t, mime)| {
|
||||
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
|
||||
(!data.is_empty()).then_some(Flavour { mime, data })
|
||||
})
|
||||
@@ -414,7 +431,7 @@ mod mac {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, PASTE_ORDER};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -448,6 +465,36 @@ mod tests {
|
||||
assert!(copied_file(vec![], &PASTE_ORDER).is_none());
|
||||
}
|
||||
|
||||
/// What another app copied as vector art is asked for under the names the platforms give it,
|
||||
/// and read as the flavour the paste imports (#1499).
|
||||
#[test]
|
||||
fn vector_art_is_read_under_each_platforms_names() {
|
||||
let mac = |m| pasteboard_types(m).iter().map(|t| t.0).collect::<Vec<_>>();
|
||||
assert_eq!(mac(PDF), ["com.adobe.pdf", "Apple PDF pasteboard type"]);
|
||||
assert_eq!(mac(EPS), ["com.adobe.encapsulated-postscript"]);
|
||||
assert_eq!(mac(SVG), ["public.svg-image"]);
|
||||
assert_eq!(pasteboard_types(BITMAP), [("public.png", PNG), ("public.tiff", "image/tiff")]);
|
||||
assert!(windows_formats(EPS).contains(&"Encapsulated PostScript") && windows_formats(PDF).contains(&"Portable Document Format"));
|
||||
for asked in PASTE_ORDER {
|
||||
// Each type's data is the flavour asked for (a bitmap its own type): what
|
||||
// `clipboard.import*` then reads. Text and metafiles come another way.
|
||||
assert!(pasteboard_types(asked).iter().all(|(_, mime)| *mime == asked || asked == BITMAP), "{asked}");
|
||||
assert_eq!(pasteboard_types(asked).is_empty(), [TEXT, EMF].contains(&asked), "{asked}");
|
||||
}
|
||||
// Vector art before text, text before bitmaps.
|
||||
let at = |m| PASTE_ORDER.iter().position(|x| *x == m).unwrap();
|
||||
assert!(at(PDF) < at(EPS) && at(EPS) < at(TEXT) && at(TEXT) < at(BITMAP));
|
||||
}
|
||||
|
||||
/// An .eps file copied in a file manager pastes as EPS.
|
||||
#[test]
|
||||
fn a_copied_eps_file_pastes() {
|
||||
let eps = dir("eps").join("equation.eps");
|
||||
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
|
||||
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
|
||||
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crlf_left_by_the_uri_list_is_ignored() {
|
||||
let red = dir("crlf").join("red.png");
|
||||
|
||||
@@ -89,3 +89,7 @@ sync_github_name = false
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
@@ -207,15 +207,15 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (-360..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
|
||||
@@ -90,7 +90,14 @@ pub fn extrude_options(p: &Value) -> Extrude {
|
||||
}
|
||||
|
||||
pub fn inflate_options(p: &Value) -> Inflate {
|
||||
Inflate { depth: num(p, "depth", 0.0), volume: num(p, "volume", 50.0), both_sides: flag(p, "bothSides", false) }
|
||||
Inflate {
|
||||
depth: num(p, "depth", 0.0),
|
||||
volume: num(p, "volume", 50.0),
|
||||
both_sides: flag(p, "bothSides", false),
|
||||
twist: num(p, "twist", 0.0),
|
||||
taper: num(p, "taper", 100.0) / 100.0,
|
||||
cap: flag(p, "cap", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape a solid is made from: a path's or a compound path's outline.
|
||||
|
||||
@@ -120,6 +120,32 @@ fn export_and_expand_bake_faces_with_their_own_ids() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A twisted, tapered, hollow Inflate (both sides) exports and expands like the others: faces with
|
||||
/// their own ids, no 3D effect left on them.
|
||||
#[test]
|
||||
fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut n = square_node();
|
||||
n.id = d.alloc_id();
|
||||
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
|
||||
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||||
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id), "{:?} twice", n.id);
|
||||
assert!(!has_solid(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&expanded, &mut ids);
|
||||
assert!(ids.len() > 20, "{} nodes", ids.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
@@ -156,3 +182,12 @@ fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||||
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
|
||||
assert!(extrude_options(&json!({})).bevel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_reads_twist_taper_and_cap() {
|
||||
let o = inflate_options(&json!({"twist": 90, "taper": 40, "cap": false}));
|
||||
assert_eq!((o.twist, o.taper, o.cap), (90.0, 0.4, false));
|
||||
let d = inflate_options(&json!({}));
|
||||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKin
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -569,6 +570,10 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id.
|
||||
fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
|
||||
let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
if separate(d, ids) {
|
||||
let id = d.alloc_id();
|
||||
return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?);
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?;
|
||||
@@ -742,9 +747,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if expand {
|
||||
break_links(d, &inst)?;
|
||||
} else {
|
||||
for i in inst {
|
||||
d.remove(i)?;
|
||||
}
|
||||
remove_each(d, &inst)?;
|
||||
}
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
@@ -871,12 +874,7 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
let n = doomed.len();
|
||||
if n > 0 {
|
||||
s.edit("Symbol Sprayer", |d, _| {
|
||||
for id in &doomed {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?;
|
||||
}
|
||||
return Ok(json!({ "count": n }));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use vectorcraft_trace as tr;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::shape_node;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
use super::*;
|
||||
|
||||
/// Name of an Image Trace group.
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -341,19 +341,16 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||
/// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.)
|
||||
fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result<Vec<NodeId>> {
|
||||
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let before = roots.iter().filter(|r| **r != top && d.position(**r).is_some_and(|(p, i, _)| p == par && i < idx)).count();
|
||||
for r in roots {
|
||||
let Some((&top, rest)) = roots.split_last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let ids = nodes.iter().map(|n| n.id).collect();
|
||||
let mut replaced = Beside::replacing(d, roots);
|
||||
for r in rest {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
replaced.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let at = idx - before;
|
||||
let mut ids = vec![];
|
||||
for (k, n) in nodes.into_iter().enumerate() {
|
||||
ids.push(d.insert(par, at + k, n)?);
|
||||
}
|
||||
replaced.put(d, top, nodes)?;
|
||||
replaced.finish(d)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -508,14 +505,14 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Arc::make_mut(e).appearance = app.into();
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, &roots);
|
||||
for r in roots.iter().filter(|r| **r != target) {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
merged.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
|
||||
d.remove(target)?;
|
||||
d.insert(par, idx, g)?;
|
||||
merged.put(d, target, vec![g])?;
|
||||
merged.finish(d)?;
|
||||
sel.set([target]);
|
||||
Ok((target, nf, ne))
|
||||
})?;
|
||||
@@ -536,11 +533,14 @@ fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = live_targets(s, p)?;
|
||||
let ids = s.edit("Release Live Paint", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let srcs: Vec<Node> = source_nodes(&n).iter().map(|c| (**c).clone()).collect();
|
||||
out.extend(replace_roots(d, &[*g], srcs)?);
|
||||
out.extend(srcs.iter().map(|c| c.id));
|
||||
released.put(d, *g, srcs)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -836,12 +836,15 @@ fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = trace_targets(s, p)?;
|
||||
let ids = s.edit("Release Image Trace", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone();
|
||||
img.visible = true;
|
||||
out.extend(replace_roots(d, &[*g], vec![img])?);
|
||||
out.push(img.id);
|
||||
released.put(d, *g, vec![img])?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
|
||||
//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles).
|
||||
//! Paste takes from them (bitmaps, plain text, PDF, EPS and Windows metafiles).
|
||||
//!
|
||||
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
|
||||
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
|
||||
@@ -27,9 +27,12 @@ pub const BITMAP: &str = "image/*";
|
||||
/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it
|
||||
/// where the platform offers it).
|
||||
pub const EMF: &str = "image/emf";
|
||||
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
|
||||
/// beside, or instead of, a PDF (#1499).
|
||||
pub const EPS: &str = "application/postscript";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
|
||||
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
|
||||
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
|
||||
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
|
||||
/// address alone pastes as text, and nothing is fetched.
|
||||
@@ -67,13 +70,14 @@ fn only_images(html: &str) -> bool {
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
"svg" => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"eps" => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
_ => return None,
|
||||
@@ -167,6 +171,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
import_emf
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importEps",
|
||||
"Load EPS into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_eps
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -345,10 +358,26 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if vectorcraft_metafile::sniff(&bytes).is_none() {
|
||||
return Err(bad(C, "the data isn't an EMF or WMF picture"));
|
||||
}
|
||||
let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
import_picture(s, p, C, "Clipboard.emf", &bytes)
|
||||
}
|
||||
|
||||
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importEps";
|
||||
let bytes = data_param(p, C)?;
|
||||
if !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
|
||||
}
|
||||
import_picture(s, p, C, "Clipboard.eps", &bytes)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
|
||||
/// opened (command `cmd`).
|
||||
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
loaded.doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the picture has no objects"));
|
||||
return Err(bad(cmd, "the picture has no objects"));
|
||||
}
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "warnings": loaded.warnings }))
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::node_path;
|
||||
use super::pathops::{Beside, node_path};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -163,10 +163,12 @@ fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
|
||||
}
|
||||
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
|
||||
let (mut all, mut removed) = (vec![], 0);
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
removed += pieces.is_empty() as usize;
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok((all, removed))
|
||||
})?;
|
||||
|
||||
@@ -10,6 +10,7 @@ use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection,
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::Beside;
|
||||
use super::select::{selectable_ids, walk_selectable};
|
||||
use super::*;
|
||||
|
||||
@@ -504,10 +505,13 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Clear Guides", |d, _| {
|
||||
d.guides.clear();
|
||||
let mut gone = Beside::replacing(d, &paths);
|
||||
for id in &paths {
|
||||
let _ = d.remove(*id);
|
||||
if d.node(*id).is_some() {
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
gone.finish(d)
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node};
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -310,20 +311,25 @@ fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
|
||||
}
|
||||
|
||||
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
|
||||
/// it; none removes it). Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
let mut it = pieces.into_iter();
|
||||
let Some(first) = it.next() else {
|
||||
d.remove(id)?;
|
||||
cut.put(d, id, vec![])?;
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let mut ids = vec![id];
|
||||
for (k, pd) in it.enumerate() {
|
||||
let mut above = vec![];
|
||||
for pd in it {
|
||||
let n = sibling_with(d, id, pd)?;
|
||||
ids.push(d.insert(parent, idx + 1 + k, n)?);
|
||||
ids.push(n.id);
|
||||
above.push(n);
|
||||
}
|
||||
set_geometry(d, id, first)?;
|
||||
if !above.is_empty() {
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
cut.put(d, id, [n].into_iter().chain(above).collect())?;
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -733,7 +739,10 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
remove_anchors_of(d, sel, id, [(si, ai)])
|
||||
let mut emptied = Beside::replacing(d, &[id]);
|
||||
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?;
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObject": removed }))
|
||||
}
|
||||
@@ -752,18 +761,26 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let removed = s.edit("Remove Anchor Points", |d, sel| {
|
||||
let mut removed = 0;
|
||||
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
|
||||
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
|
||||
/// the path went.
|
||||
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
fn remove_anchors_of(
|
||||
d: &mut Document,
|
||||
sel: &mut Selection,
|
||||
emptied: &mut Beside,
|
||||
id: NodeId,
|
||||
anchors: impl IntoIterator<Item = (usize, usize)>,
|
||||
) -> Result<bool> {
|
||||
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
|
||||
// The last first, so the indexes still to come stay valid.
|
||||
anchors.sort_unstable_by(|a, b| b.cmp(a));
|
||||
@@ -777,7 +794,7 @@ fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors:
|
||||
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
let gone = path.is_empty();
|
||||
if gone {
|
||||
d.remove(id)?;
|
||||
emptied.put(d, id, vec![])?;
|
||||
sel.remove(id);
|
||||
} else {
|
||||
sel.anchors.remove(&id);
|
||||
@@ -920,9 +937,11 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit("Knife", |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok(all)
|
||||
})?;
|
||||
@@ -960,9 +979,11 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok(all)
|
||||
})?;
|
||||
@@ -1027,17 +1048,46 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
|
||||
open.anchors.push(open.anchors[0]);
|
||||
m.push(false);
|
||||
open.closed = false;
|
||||
let mut res = smooth_open(&open.anchors, &m, tol);
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol);
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
r0.h_in = l.h_in;
|
||||
}
|
||||
return Some(SubPath::new(res, true));
|
||||
}
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false))
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false))
|
||||
}
|
||||
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
|
||||
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
|
||||
/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver
|
||||
/// is outside.
|
||||
fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> {
|
||||
const STEPS: u32 = 32;
|
||||
let step = 1.0 / f64::from(STEPS);
|
||||
// Back from the end in reach to the first sample outside, then halving to the crossing.
|
||||
let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?;
|
||||
let (mut lo, mut hi) = (outside, outside + step);
|
||||
for _ in 0..20 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if near(c.eval(mid)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
// A straight segment's parts stay straight, their handles on their ends.
|
||||
let straight = c.p1 == c.p0 && c.p2 == c.p3;
|
||||
let part = |range| {
|
||||
let s = c.subsegment(range);
|
||||
if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s }
|
||||
};
|
||||
(hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0)))
|
||||
}
|
||||
|
||||
/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments
|
||||
/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under
|
||||
/// it, and the rest of a long segment next to it stays as it was (#1512).
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec<Anchor> {
|
||||
let n = anchors.len();
|
||||
let mut runs = vec![];
|
||||
let mut i = 0;
|
||||
@@ -1058,8 +1108,27 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
|
||||
if hi - lo < 2 {
|
||||
continue;
|
||||
}
|
||||
let new = refit_run(&out[lo..=hi], tol);
|
||||
out.splice(lo..=hi, new);
|
||||
let mut piece = out[lo..=hi].to_vec();
|
||||
// The anchors outside the reach, kept with the part of their segment that is outside.
|
||||
let (mut head, mut tail) = (None, None);
|
||||
if hi > b
|
||||
&& let [.., inner, outer] = piece.as_mut_slice()
|
||||
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near)
|
||||
{
|
||||
inner.h_out = reach.p2;
|
||||
tail = Some(Anchor { h_in: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, reach.p1, far.p2);
|
||||
}
|
||||
if lo < a
|
||||
&& let [outer, inner, ..] = piece.as_mut_slice()
|
||||
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near)
|
||||
{
|
||||
inner.h_in = reach.p2;
|
||||
head = Some(Anchor { h_out: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
|
||||
}
|
||||
let new = refit_run(&piece, tol);
|
||||
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1256,9 +1325,7 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
for id in others {
|
||||
d.remove(id)?;
|
||||
}
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -558,10 +558,20 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them. The copies
|
||||
/// take new ids in the order of `roots`. Roots listed twice, or inside another root, are copied one
|
||||
/// at a time: each copy is made from the document as the copies before it left it.
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them
|
||||
/// ([`copy_above`]).
|
||||
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
|
||||
let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy `roots` directly above each original and return the copies' ids. `change` gets the
|
||||
/// document, each original and its copy before the copy goes in, and must leave the document's
|
||||
/// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside
|
||||
/// another root, are copied one at a time: each copy is made from the document as the copies
|
||||
/// before it left it.
|
||||
pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result<Vec<NodeId>> {
|
||||
let paths = d.index_paths(roots.iter().copied());
|
||||
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
@@ -570,7 +580,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
|
||||
for id in roots {
|
||||
let Some(n) = paths.get(id).and_then(|p| d.node_at(p)).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
change(d, &n, &mut c);
|
||||
copies.push((*id, vec![c]));
|
||||
}
|
||||
let out: Vec<NodeId> = copies.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
|
||||
@@ -582,12 +592,11 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
|
||||
let Some((par, idx, _)) = d.position(*id) else { continue };
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
change(d, &n, &mut c);
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
out
|
||||
};
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -382,7 +382,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
|
||||
// that silently lacks it.
|
||||
if f.raster && vectorcraft_render::decode_failures() > failed {
|
||||
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
|
||||
}
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
// themselves: SVG only when it outlines or embeds fonts).
|
||||
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
|
||||
|
||||
@@ -192,7 +192,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
@@ -263,6 +263,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
|
||||
}
|
||||
|
||||
@@ -392,7 +393,18 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
|
||||
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
|
||||
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
|
||||
/// nothing for them, so Open, Place and the Links panel say it (#1479). `None` for images that
|
||||
/// decode, and for bytes that aren't an image.
|
||||
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
use image::ImageDecoder as _;
|
||||
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = d.dimensions();
|
||||
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
|
||||
@@ -163,3 +163,20 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
// the renderer can't decode it and draws nothing, which the export must say.
|
||||
let mut s = session(72.0, 36.0, 1);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4;
|
||||
png.truncate(cut);
|
||||
s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
}
|
||||
@@ -35,7 +35,7 @@ use vectorcraft_render::effects;
|
||||
|
||||
use super::edit::roots_of;
|
||||
use super::menucmds::{MAX_PIXELS, isolated_doc, unique_key};
|
||||
use super::pathops::{node_path, outline_strokes, outline_strokes_under, shape_node};
|
||||
use super::pathops::{node_path, outline_strokes, outline_strokes_under, separate, shape_node};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1038,7 +1038,10 @@ pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Opt
|
||||
|
||||
fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||
let (mut vector, mut rasterized) = (0, 0);
|
||||
let mut ids = vec![];
|
||||
let (mut ids, mut flat) = (vec![], vec![]);
|
||||
// The groups replace their objects in one pass at the end. When an object is listed twice or
|
||||
// lies inside another listed one, each group replaces its objects as it is made instead.
|
||||
let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::<Vec<_>>());
|
||||
for f in plan.flat {
|
||||
// A group always has objects.
|
||||
let Some(&top) = f.roots.last() else { continue };
|
||||
@@ -1060,13 +1063,23 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
||||
children.push(Arc::new(n));
|
||||
vector += 1;
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let g = Node::group(d.alloc_id(), children);
|
||||
ids.push(d.insert(par, idx + 1, g)?);
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
ids.push(g.id);
|
||||
if one_pass {
|
||||
// The group replaces the topmost object, and the other objects are removed.
|
||||
flat.extend(f.roots.iter().filter(|r| **r != top).map(|r| (*r, vec![])));
|
||||
flat.push((top, vec![g]));
|
||||
} else {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !flat.is_empty() {
|
||||
d.insert_beside(flat, true)?;
|
||||
}
|
||||
let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] };
|
||||
for mut id in plan.kept {
|
||||
if o.text_to_outlines {
|
||||
@@ -1113,9 +1126,8 @@ fn outline_texts_under(d: &mut Document, root: NodeId) -> Result<NodeId> {
|
||||
for t in texts {
|
||||
let Some(o) = outlined_text(&t) else { continue };
|
||||
let o = d.reid(&o);
|
||||
let (par, idx, _) = d.position(t.id).ok_or(EngineError::NoNode(t.id))?;
|
||||
let nid = d.insert(par, idx, o)?;
|
||||
d.remove(t.id)?;
|
||||
let nid = o.id;
|
||||
*d.node_mut(t.id).ok_or(EngineError::NoNode(t.id))? = o;
|
||||
if t.id == root {
|
||||
out = nid;
|
||||
}
|
||||
|
||||
@@ -232,12 +232,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some("Vector Halftone".into());
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
if !keep {
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
if keep {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
} else {
|
||||
d.wrap(&roots, |_| g)?;
|
||||
}
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
|
||||
@@ -234,23 +234,47 @@ pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Every layer and sublayer, depth first.
|
||||
fn all_layers(d: &Document) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
/// The number and the names of a document's layers and sublayers, from which [`NewLayers::make`]
|
||||
/// names and colors a new layer. [`NewLayers::of`] reads them in one walk of the document, and
|
||||
/// [`NewLayers::add`] counts each new layer as it goes in.
|
||||
pub(crate) struct NewLayers {
|
||||
count: usize,
|
||||
taken: HashSet<String>,
|
||||
}
|
||||
|
||||
impl NewLayers {
|
||||
pub(crate) fn of(d: &Document) -> Self {
|
||||
let mut layers = Self { count: 0, taken: HashSet::new() };
|
||||
d.walk(|n| layers.note(n));
|
||||
layers
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`, as [`Document::next_layer_name`]
|
||||
/// gives it) in the next layer color.
|
||||
pub(crate) fn make(&self, d: &mut Document, name: Option<&str>) -> Node {
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| (self.count + 1..).map(|i| format!("Layer {i}")).find(|name| !self.taken.contains(name)).unwrap_or_default());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((self.count % LAYER_COLORS.len()) as u8))
|
||||
}
|
||||
|
||||
/// Count `n`, a layer going into the document, and the layers inside it.
|
||||
pub(crate) fn add(&mut self, n: &Node) {
|
||||
n.walk(&mut |c| self.note(c));
|
||||
}
|
||||
|
||||
fn note(&mut self, n: &Node) {
|
||||
if n.is_layer() {
|
||||
out.push(n);
|
||||
self.count += 1;
|
||||
self.taken.extend(n.name.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`) in the next layer colour.
|
||||
pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
let n = all_layers(d).len();
|
||||
let name = name.map(str::to_string).unwrap_or_else(|| d.next_layer_name());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))
|
||||
NewLayers::of(d).make(d, name)
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
@@ -464,13 +488,12 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.delete", "no such rows"));
|
||||
}
|
||||
if st.doc.layers.iter().all(|l| ids.contains(&l.id)) {
|
||||
let listed: HashSet<NodeId> = ids.iter().copied().collect();
|
||||
if st.doc.layers.iter().all(|l| listed.contains(&l.id)) {
|
||||
return Err(EngineError::Other("a document needs at least one layer".into()));
|
||||
}
|
||||
s.edit("Delete Selection", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&ids)?;
|
||||
// The guides on the deleted layers go with them.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
sel.prune(d);
|
||||
@@ -491,18 +514,12 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("layer.duplicate", "no such rows"));
|
||||
}
|
||||
let copies = s.edit("Duplicate Selection", |d, _| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let mut c = d.reid(&n);
|
||||
super::edit::copy_above(d, &ids, |d, n, c| {
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
d.copy_layer_guides(&n, &c);
|
||||
d.copy_layer_guides(n, c);
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
})?;
|
||||
s.doc_mut()?.layer_rows = copies.clone();
|
||||
Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
@@ -579,14 +596,23 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = s.edit("Collect in New Layer", |d, sel| {
|
||||
// Where the topmost of them is: beside it in its layer, or on top of the layer holding
|
||||
// its group.
|
||||
let (parent, index) = match d.position(top) {
|
||||
Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1),
|
||||
_ => (d.layer_containing(top), usize::MAX),
|
||||
};
|
||||
let layer = make_layer(d, None);
|
||||
let lid = d.insert(parent, index, layer)?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(lid), usize::MAX)?;
|
||||
let beside = d.position(top).is_some_and(|(par, _, _)| par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)));
|
||||
let holder = d.layer_containing(top);
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
if beside {
|
||||
d.wrap(&ids, |rows| {
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = rows;
|
||||
}
|
||||
layer
|
||||
})?;
|
||||
} else {
|
||||
let rows = d.remove_many(&ids)?;
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = rows;
|
||||
}
|
||||
d.insert(holder, usize::MAX, layer)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
@@ -683,19 +709,20 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let label = if copy { "Duplicate Rows" } else { "Move Rows" };
|
||||
let moved = s.edit(label, |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let nodes = if copy {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
}
|
||||
nodes.push(Arc::new(c));
|
||||
}
|
||||
nodes
|
||||
} else {
|
||||
d.remove_many(&ids)?
|
||||
};
|
||||
let count = d.children(parent).map_or(0, Vec::len);
|
||||
let mut index = match anchor {
|
||||
None => count,
|
||||
@@ -708,11 +735,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
|
||||
index = index.max(1);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.push(d.insert(parent, index, n)?);
|
||||
index += 1;
|
||||
}
|
||||
let out: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
|
||||
let ch = d.children_mut(parent)?;
|
||||
let index = index.min(ch.len());
|
||||
ch.splice(index..index, nodes);
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(out)
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{make_layer, row_roots, rows_param};
|
||||
use super::layer::{NewLayers, row_roots, rows_param};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -234,28 +234,23 @@ fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// another) into layer `into`, keeping the document's stacking order, then delete the emptied
|
||||
/// layers.
|
||||
fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
let listed: HashSet<NodeId> = others.iter().copied().collect();
|
||||
let own: Vec<NodeId> =
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default();
|
||||
let mut moved = vec![];
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !listed.contains(c)).collect()).unwrap_or_default();
|
||||
let (mut moved, mut emptied) = (vec![], vec![]);
|
||||
for id in others {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if n.is_layer() {
|
||||
moved.extend(n.children().into_iter().flatten().map(|c| c.id));
|
||||
emptied.push(*id);
|
||||
} else {
|
||||
moved.push(*id);
|
||||
}
|
||||
}
|
||||
// Where everything is now: the merged layer stacks its contents in this order.
|
||||
let keys: HashMap<NodeId, Vec<usize>> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect();
|
||||
let mut taken = vec![];
|
||||
for id in &moved {
|
||||
taken.push(d.remove(*id)?);
|
||||
}
|
||||
for id in others {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
}
|
||||
let taken = d.remove_many(&moved)?;
|
||||
d.remove_many(&emptied)?;
|
||||
let ch = d.children_mut(Some(into))?;
|
||||
let mut all = std::mem::take(ch);
|
||||
all.extend(taken);
|
||||
@@ -312,9 +307,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
s.edit("Flatten Artwork", |d, sel| {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
d.remove_many(&hidden)?;
|
||||
// Hidden layers are discarded with their guides.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
merge_into(d, into, &others)?;
|
||||
@@ -352,39 +345,38 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| {
|
||||
// A group's objects go into sublayers of the layer holding it, where it was.
|
||||
let (parent, mut index) = if group {
|
||||
let (parent, index) = if group {
|
||||
let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) }
|
||||
} else {
|
||||
(id, 0)
|
||||
};
|
||||
let mut originals = vec![];
|
||||
for o in &objects {
|
||||
originals.push(Arc::unwrap_or_clone(d.remove(*o)?));
|
||||
}
|
||||
let originals = d.remove_many(&objects)?;
|
||||
if group {
|
||||
d.remove(id)?;
|
||||
}
|
||||
let mut new = vec![];
|
||||
let mut names = NewLayers::of(d);
|
||||
let mut layers = Vec::with_capacity(originals.len());
|
||||
let last = originals.len().saturating_sub(1);
|
||||
for k in 0..originals.len() {
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
let held: Vec<Node> = if build {
|
||||
let mut layer = names.make(d, None);
|
||||
let held: Vec<Arc<Node>> = if build {
|
||||
// Copies of the objects below and itself; the top layer keeps the originals.
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect()
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { Arc::new(d.reid(o)) }).collect()
|
||||
} else {
|
||||
originals.get(k).cloned().into_iter().collect()
|
||||
};
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
ch.extend(held.into_iter().map(Arc::new));
|
||||
ch.extend(held);
|
||||
}
|
||||
let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) };
|
||||
d.insert(Some(parent), at, layer)?;
|
||||
index = at.saturating_add(1);
|
||||
new.push(lid);
|
||||
names.add(&layer);
|
||||
layers.push(Arc::new(layer));
|
||||
}
|
||||
let new: Vec<NodeId> = layers.iter().map(|l| l.id).collect();
|
||||
let ch = d.children_mut(Some(parent))?;
|
||||
let at = index.min(ch.len());
|
||||
ch.splice(at..at, layers);
|
||||
sel.prune(d);
|
||||
Ok(new)
|
||||
})?;
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Links",
|
||||
[],
|
||||
None,
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image too large to draw, document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -1011,7 +1011,14 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (link, mut row) = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
(im.link.as_ref(), json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height }))
|
||||
let mut row = json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height });
|
||||
// Only an image that could be past the decode allowance (at 16 bytes a pixel, the
|
||||
// most a decoder gives) has its header read for this.
|
||||
let large = im.width as u64 * im.height as u64 > fileio::MAX_RASTER_ALLOC / 16;
|
||||
if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::too_large_note(&b.bytes)) {
|
||||
row["note"] = json!(note);
|
||||
}
|
||||
(im.link.as_ref(), row)
|
||||
}
|
||||
NodeKind::PlacedDocument(p) => (Some(&p.link), document_row(p)),
|
||||
_ => continue,
|
||||
|
||||
@@ -18,6 +18,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -360,14 +361,11 @@ fn has_mesh(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// Wrap `roots` (paint order) into a new node built by `make`, placed where the topmost root was.
|
||||
fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec<Arc<Node>>) -> Node) -> Result<NodeId> {
|
||||
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing to wrap".into()))?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, make(id, nodes))?;
|
||||
for r in roots {
|
||||
d.remove(*r)?;
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("nothing to wrap".into()));
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
d.wrap(roots, |nodes| make(id, nodes))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
}
|
||||
@@ -546,6 +544,7 @@ fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let blends = selected_of(s, is_blend);
|
||||
let (ids, spines, groups) = s.edit("Release Blend", |d, sel| {
|
||||
let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]);
|
||||
let mut released = Beside::replacing(d, &blends);
|
||||
for b in &blends {
|
||||
let Some(n) = d.node(*b).cloned() else { continue };
|
||||
let NodeKind::Blend { children, spec } = &n.kind else { continue };
|
||||
@@ -572,8 +571,9 @@ fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
groups.push(g.id);
|
||||
nodes = vec![g];
|
||||
}
|
||||
replace_with(d, *b, nodes)?;
|
||||
released.put(d, *b, nodes)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
if groups.is_empty() {
|
||||
sel.set(spines.iter().chain(&keys).copied());
|
||||
} else {
|
||||
@@ -1171,6 +1171,7 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let envs = selected_of(s, is_envelope);
|
||||
let ids = s.edit("Release Envelope", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &envs);
|
||||
for e in &envs {
|
||||
let Some(n) = d.node(*e).cloned() else { continue };
|
||||
let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue };
|
||||
@@ -1179,8 +1180,10 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
shape.id = d.alloc_id();
|
||||
nodes.push(shape);
|
||||
}
|
||||
out.extend(replace_with(d, *e, nodes)?);
|
||||
out.extend(nodes.iter().map(|c| c.id));
|
||||
released.put(d, *e, nodes)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -443,9 +443,10 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||||
let tmp = isolated_doc(&st.doc, nodes);
|
||||
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
let id = s.edit("Rasterize", |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
@@ -455,11 +456,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some((path, rule)) = clip {
|
||||
node = super::rasterfx::clip_group(d, path, rule, node);
|
||||
}
|
||||
let id = node.id;
|
||||
d.insert(par, idx + 1, node)?;
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
let id = d.wrap(&roots, |_| node)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -625,7 +622,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
const C: &str = "object.cropImage";
|
||||
let st = s.doc()?;
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
|
||||
// A CMYK image (kept as its ink amounts) has no alpha: nothing to cut.
|
||||
if blob.is_cmyk() {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8();
|
||||
let (w, h) = px.dimensions();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (x, y, p) in px.enumerate_pixels() {
|
||||
@@ -818,7 +826,7 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let n = s.edit("Convert to Artboards", |d, sel| {
|
||||
let mut n = 0;
|
||||
let mut converted = vec![];
|
||||
for id in &roots {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
@@ -832,14 +840,14 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
});
|
||||
d.remove(*id)?;
|
||||
n += 1;
|
||||
converted.push(*id);
|
||||
}
|
||||
if n == 0 {
|
||||
if converted.is_empty() {
|
||||
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
|
||||
}
|
||||
d.remove_many(&converted)?;
|
||||
sel.clear();
|
||||
Ok(n)
|
||||
Ok(converted.len())
|
||||
})?;
|
||||
Ok(json!({ "artboards": n }))
|
||||
}
|
||||
|
||||
@@ -719,42 +719,39 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let id = s.edit("Make Compound Path", |d, sel| {
|
||||
let appearance = d.node(backmost).ok_or(EngineError::NoNode(backmost))?.appearance.clone();
|
||||
let mut paths: Vec<Arc<Node>> = vec![];
|
||||
d.node(backmost).ok_or(EngineError::NoNode(backmost))?;
|
||||
let mut units: Vec<Vec<Arc<Node>>> = vec![];
|
||||
let mut appearance = None;
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
match &n.kind {
|
||||
NodeKind::Path { .. } => paths.push(Arc::new(n)),
|
||||
NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()),
|
||||
NodeKind::Group { .. } => {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. }) {
|
||||
paths.push(Arc::new(c.clone()))
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => return Err(EngineError::Other("compound paths can only contain paths".into())),
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) {
|
||||
return Err(EngineError::Other("compound paths can only contain paths".into()));
|
||||
}
|
||||
compound_members(n, &mut appearance, &mut units);
|
||||
}
|
||||
let cid = d.alloc_id();
|
||||
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
|
||||
c.appearance = appearance;
|
||||
c.appearance = appearance.unwrap_or_default();
|
||||
let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?;
|
||||
// The paths in front run against the backmost one, so where they overlap it they cut holes
|
||||
// under the non-zero fill rule (#1197).
|
||||
// under the non-zero fill rule (#1197). A compound path's paths turn together, by its
|
||||
// first: its own holes (a letter's counters) stay holes.
|
||||
let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum());
|
||||
let back = paths.first().map_or(0.0, |p| direction(p));
|
||||
for (i, mut p) in paths.into_iter().enumerate() {
|
||||
let pn = Arc::make_mut(&mut p);
|
||||
pn.appearance = Appearance::default().into();
|
||||
let flip = i > 0 && back != 0.0 && direction(pn) == back;
|
||||
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
|
||||
*live = None;
|
||||
if flip {
|
||||
path.reverse();
|
||||
let first = |unit: &[Arc<Node>]| unit.first().map_or(0.0, |p| direction(p));
|
||||
let back = units.first().map_or(0.0, |u| first(u));
|
||||
for (i, unit) in units.into_iter().enumerate() {
|
||||
let flip = i > 0 && back != 0.0 && first(&unit) == back;
|
||||
for mut p in unit {
|
||||
let pn = Arc::make_mut(&mut p);
|
||||
pn.appearance = Appearance::default().into();
|
||||
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
|
||||
*live = None;
|
||||
if flip {
|
||||
path.reverse();
|
||||
}
|
||||
}
|
||||
ch.push(p);
|
||||
}
|
||||
ch.push(p);
|
||||
}
|
||||
d.wrap(&ids, |_| c)?;
|
||||
sel.set([cid]);
|
||||
@@ -763,6 +760,20 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// The paths in `n` (a path, a compound path, or a group searched in depth) onto `out`, in paint
|
||||
/// order: each path alone, a compound path's paths together. Into `paint` goes the appearance of
|
||||
/// the backmost found so far: the path's own, or its compound path's, which is what painted it. A
|
||||
/// group has no paint of its own to give: a group of outlined type made a compound path that drew
|
||||
/// nothing.
|
||||
fn compound_members(n: &Node, paint: &mut Option<vectorcraft_doc::Shared<Appearance>>, out: &mut Vec<Vec<Arc<Node>>>) {
|
||||
match (&n.kind, n.children()) {
|
||||
(NodeKind::Path { .. }, _) => out.push(vec![Arc::new(n.clone())]),
|
||||
(NodeKind::Compound { .. }, Some(paths)) if !paths.is_empty() => out.push(paths.clone()),
|
||||
(_, children) => return children.into_iter().flatten().for_each(|c| compound_members(c, paint, out)),
|
||||
}
|
||||
paint.get_or_insert_with(|| n.appearance.clone());
|
||||
}
|
||||
|
||||
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
|
||||
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
|
||||
fn releases(n: &Node) -> bool {
|
||||
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection};
|
||||
|
||||
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
|
||||
use super::pathops::{Beside, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -189,6 +190,9 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Several objects are grouped so they share one mask.
|
||||
let target = if let [one] = art {
|
||||
*one
|
||||
} else if separate(d, art) {
|
||||
let gid = d.alloc_id();
|
||||
d.wrap(art, |children| Node::group(gid, children))?
|
||||
} else {
|
||||
let last = art[art.len() - 1];
|
||||
let (par, idx, _) = d.position(last).ok_or(EngineError::NoNode(last))?;
|
||||
@@ -223,16 +227,16 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Leave mask editing first: its art goes back into the mask, which is released below.
|
||||
finish(d, sel);
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::above(d, &ids);
|
||||
for id in &ids {
|
||||
let Some(m) = d.node_mut(*id).and_then(|n| n.extras_mut().mask.take()) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
out.push(*id);
|
||||
for (k, part) in mask_parts(&m.art).iter().enumerate() {
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let art = d.reid(part);
|
||||
out.push(d.insert(par, idx + 1 + k, art)?);
|
||||
}
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let parts: Vec<Node> = mask_parts(&m.art).iter().map(|part| d.reid(part)).collect();
|
||||
out.extend(parts.iter().map(|p| p.id));
|
||||
released.put(d, *id, parts)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -136,10 +136,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val
|
||||
// Its guides go with it.
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
for id in &art {
|
||||
// Always there: none of the art is inside another of it.
|
||||
let _ = d.remove(*id);
|
||||
}
|
||||
d.remove_many(&art)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -161,13 +158,7 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec
|
||||
if let Some(id) = d.artboards.get(index).map(|a| a.id) {
|
||||
d.copy_artboard_guides(src.id, id, dv);
|
||||
}
|
||||
let mut copies = Vec::with_capacity(art.len());
|
||||
for id in art {
|
||||
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(Affine::translate(dv), sc);
|
||||
copies.push(d.insert(parent, at + 1, c)?);
|
||||
}
|
||||
let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?;
|
||||
Ok((index, copies))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -466,6 +467,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let segments = st.selection.segments.clone();
|
||||
s.edit("Clear", |d, selection| {
|
||||
let mut survivors = vec![];
|
||||
let mut emptied = Beside::replacing(d, &sel.keys().copied().collect::<Vec<_>>());
|
||||
for (id, set) in &sel {
|
||||
let remove_node = {
|
||||
let path = path_mut(d, *id)?;
|
||||
@@ -499,11 +501,12 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
path.subpaths.is_empty()
|
||||
};
|
||||
if remove_node {
|
||||
d.remove(*id)?;
|
||||
emptied.put(d, *id, vec![])?;
|
||||
} else if segments.contains_key(id) {
|
||||
survivors.push(*id);
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
// Another Delete removes the remaining path after a segment cut. Anchor Delete keeps
|
||||
// its existing deselection behavior.
|
||||
selection.set(survivors);
|
||||
@@ -625,11 +628,12 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit("Cut Path", |d, sel| {
|
||||
let (mut all, mut picked) = (vec![], vec![]);
|
||||
let mut cut = Beside::replacing(d, &cuts.iter().map(|(id, ..)| *id).collect::<Vec<_>>());
|
||||
for (id, subs, starts) in cuts {
|
||||
// A path of one subpath becomes one path per piece (the first keeps the id); the
|
||||
// pieces of a compound shape stay subpaths of it.
|
||||
if d.node(id).and_then(|n| n.path_data()).is_some_and(|pd| pd.subpaths.len() == 1) {
|
||||
let ids = super::draw2::replace_with_pieces(d, id, subs.into_iter().map(PathData::single).collect())?;
|
||||
let ids = super::draw2::replace_with_pieces(d, &mut cut, id, subs.into_iter().map(PathData::single).collect())?;
|
||||
picked.extend(ids.iter().zip(&starts).filter(|(_, s)| **s).map(|(id, _)| (*id, (0, 0))));
|
||||
all.extend(ids);
|
||||
} else {
|
||||
@@ -638,6 +642,7 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
all.push(id);
|
||||
}
|
||||
}
|
||||
cut.finish(d)?;
|
||||
// Each cut leaves one of its two coincident anchors selected, so a drag pulls the ends apart.
|
||||
sel.clear();
|
||||
for (id, a) in picked {
|
||||
|
||||
@@ -286,6 +286,73 @@ fn shape_node_styled(d: &mut Document, path: PathData, style: &Style) -> Node {
|
||||
n
|
||||
}
|
||||
|
||||
/// Whether `ids` are separate objects, as [`Document::remove_many`] and [`Document::insert_beside`]
|
||||
/// take them: each one is in the tree and listed once, and none lies inside another listed one.
|
||||
pub(crate) fn separate(d: &Document, ids: &[NodeId]) -> bool {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut found: Vec<_> = ids.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
found.len() == ids.len() && found.windows(2).all(|w| !w[1].starts_with(w[0]))
|
||||
}
|
||||
|
||||
/// Lists of nodes that go in place of objects, or directly above them; an empty list in place of
|
||||
/// an object removes it. When the objects are [`separate`], [`Beside::put`] holds each list and
|
||||
/// leaves the tree as it is, and [`Beside::finish`] inserts them all in one pass over the children
|
||||
/// of each parent ([`Document::insert_beside`]). Otherwise [`Beside::put`] inserts each list at
|
||||
/// once, where its object is at that moment.
|
||||
pub(crate) struct Beside {
|
||||
replace: bool,
|
||||
waiting: Option<Vec<(NodeId, Vec<Node>)>>,
|
||||
}
|
||||
|
||||
impl Beside {
|
||||
/// Lists that go in place of objects among `ids`.
|
||||
pub(crate) fn replacing(d: &Document, ids: &[NodeId]) -> Self {
|
||||
Beside { replace: true, waiting: separate(d, ids).then(Vec::new) }
|
||||
}
|
||||
|
||||
/// Lists that go directly above objects among `ids`.
|
||||
pub(crate) fn above(d: &Document, ids: &[NodeId]) -> Self {
|
||||
Beside { replace: false, waiting: separate(d, ids).then(Vec::new) }
|
||||
}
|
||||
|
||||
/// Put `nodes`, in their order, in place of object `id` or directly above it.
|
||||
pub(crate) fn put(&mut self, d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<()> {
|
||||
if let Some(waiting) = &mut self.waiting {
|
||||
waiting.push((id, nodes));
|
||||
return Ok(());
|
||||
}
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let at = if self.replace {
|
||||
d.remove(id)?;
|
||||
idx
|
||||
} else {
|
||||
idx + 1
|
||||
};
|
||||
for (k, n) in nodes.into_iter().enumerate() {
|
||||
d.insert(parent, at + k, n)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert the lists that [`Beside::put`] held.
|
||||
pub(crate) fn finish(self, d: &mut Document) -> Result<()> {
|
||||
match self.waiting {
|
||||
Some(waiting) if !waiting.is_empty() => Ok(d.insert_beside(waiting, self.replace)?),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `ids` through a [`Beside`], which removes them in one pass when they are [`separate`].
|
||||
pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> {
|
||||
let mut gone = Beside::replacing(d, ids);
|
||||
for id in ids {
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
gone.finish(d)
|
||||
}
|
||||
|
||||
// ---------- Pathfinder ----------
|
||||
|
||||
fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
@@ -305,7 +372,9 @@ fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
|
||||
fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
let shape_mode =
|
||||
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
let label = pf_label(op);
|
||||
@@ -371,7 +440,6 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
if results.iter().all(|r| r.path.is_empty()) {
|
||||
return Err(EngineError::Other(format!("{label} produced an empty result")));
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let new_node = if shape_mode {
|
||||
let r =
|
||||
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
|
||||
@@ -403,11 +471,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
Node::group(gid, children)
|
||||
};
|
||||
let nid = new_node.id;
|
||||
d.insert(par, idx + 1, new_node)?;
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
let nid = d.wrap(&roots, |_| new_node)?;
|
||||
sel.set([nid]);
|
||||
Ok(vec![nid])
|
||||
})?;
|
||||
@@ -433,7 +497,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut lv = vec![];
|
||||
@@ -460,12 +524,13 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
Node::group(gid, made.into_iter().map(Arc::new).collect())
|
||||
};
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
out.push(d.insert(par, idx + 1, new)?);
|
||||
out.push(new.id);
|
||||
above.push((*id, vec![new]));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Offset Path: select paths".into()));
|
||||
}
|
||||
d.insert_beside(above, false)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -614,9 +679,8 @@ pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&
|
||||
let (mut replacement, mut changed) = (root, 0);
|
||||
for l in lv {
|
||||
let Some(new) = f(d, &l) else { continue };
|
||||
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
|
||||
let nid = d.insert(par, idx, new)?;
|
||||
d.remove(l.id)?;
|
||||
let nid = new.id;
|
||||
*d.node_mut(l.id).ok_or(EngineError::NoNode(l.id))? = new;
|
||||
changed += 1;
|
||||
if l.id == root {
|
||||
replacement = nid;
|
||||
@@ -840,12 +904,13 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "removed": 0 }));
|
||||
}
|
||||
s.edit("Clean Up", |d, _| {
|
||||
let mut gone = Beside::replacing(d, &found);
|
||||
for id in &found {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
gone.finish(d)
|
||||
})?;
|
||||
Ok(json!({ "removed": found.len() }))
|
||||
}
|
||||
@@ -885,6 +950,7 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
|
||||
});
|
||||
let mut out = vec![];
|
||||
let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat());
|
||||
for t in targets {
|
||||
let Some(n) = d.node(t).cloned() else { continue };
|
||||
let Some((tp, tr)) = node_path(&n) else { continue };
|
||||
@@ -893,14 +959,12 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if inside.is_empty() || outside.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (par, idx, _) = d.position(t).ok_or(EngineError::NoNode(t))?;
|
||||
d.remove(t)?;
|
||||
for (k, piece) in [outside, inside].into_iter().enumerate() {
|
||||
let node = shape_node(d, piece, Some(&n));
|
||||
out.push(d.insert(par, idx + k, node)?);
|
||||
}
|
||||
let pieces: Vec<Node> = [outside, inside].into_iter().map(|piece| shape_node(d, piece, Some(&n))).collect();
|
||||
out.extend(pieces.iter().map(|p| p.id));
|
||||
cut.put(d, t, pieces)?;
|
||||
}
|
||||
d.remove(cutter_id)?;
|
||||
cut.put(d, cutter_id, vec![])?;
|
||||
cut.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -18,6 +18,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -633,15 +634,12 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<No
|
||||
return Ok(json!({ "id": r.0 }));
|
||||
}
|
||||
let roots = roots(s, p)?;
|
||||
let Some(&top) = roots.last() else { return Err(EngineError::Other("select the art to repeat".into())) };
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("select the art to repeat".into()));
|
||||
}
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(id, NodeKind::Repeat(make(nodes))))?;
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
d.wrap(&roots, |nodes| Node::new(id, NodeKind::Repeat(make(nodes))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -682,15 +680,14 @@ fn repeat_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let reps = selected_repeats(s);
|
||||
let ids = s.edit("Release Repeat", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &reps);
|
||||
for r in &reps {
|
||||
let Some(n) = d.node(*r).cloned() else { continue };
|
||||
let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
d.remove(*r)?;
|
||||
for (k, c) in n.children().into_iter().flatten().enumerate() {
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, (**c).clone())?;
|
||||
}
|
||||
let art: Vec<Node> = n.children().into_iter().flatten().map(|c| (**c).clone()).collect();
|
||||
out.extend(art.iter().map(|c| c.id));
|
||||
released.put(d, *r, art)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -183,7 +183,8 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
|
||||
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link, board: None });
|
||||
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||
}
|
||||
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
||||
let (mut doc, warnings) = match format.id {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shaper compositions are groups of generated fills/strokes with transparent, editable source
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
@@ -380,7 +381,10 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
ids.push(n.id)
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
// Groups whose source art is all gone are removed through `emptied`, which is made when the
|
||||
// first one is found.
|
||||
let mut emptied: Option<Beside> = None;
|
||||
for &id in &ids {
|
||||
let Some(current) = d.node(id).cloned() else { continue };
|
||||
let Some(old) = before.node(id) else { continue };
|
||||
let src = sources(¤t);
|
||||
@@ -395,7 +399,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
if src.is_empty() {
|
||||
d.remove(id)?;
|
||||
emptied.get_or_insert_with(|| Beside::replacing(d, &ids)).put(d, id, vec![])?;
|
||||
continue;
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
@@ -494,7 +498,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
*ch = children;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
emptied.map_or(Ok(()), |e| e.finish(d))
|
||||
}
|
||||
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
@@ -670,9 +674,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut group = Node::group(id, children);
|
||||
group.name = Some("Shaper Group".into());
|
||||
group.extras_mut().shaper = Some(Box::new(spec));
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&roots)?;
|
||||
d.insert(parent, index.saturating_sub(earlier), group)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
|
||||
@@ -1596,6 +1596,59 @@ fn inspect_reports_compound_paint_and_blend() {
|
||||
assert!(plain.get("blend").is_none(), "Normal is omitted: {plain}");
|
||||
}
|
||||
|
||||
/// A compound path made of a group (outlined type is one) takes the paint of the backmost path in
|
||||
/// it, not the group's own (none): it drew nothing. A compound path in the group gives the paint
|
||||
/// that painted its paths.
|
||||
#[test]
|
||||
fn compound_of_a_group_takes_its_backmost_paths_paint() {
|
||||
let paint = |s: &Session, id: u64| (*s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance).clone();
|
||||
let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let b = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let red = paint(&s, a.0);
|
||||
assert_ne!(red, paint(&s, b.0));
|
||||
assert_ne!(red, Default::default());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
made(&mut s, "object.group");
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
assert_eq!(paint(&s, c), red, "the backmost path's paint");
|
||||
// Grouped again and joined with a path in front: the paint the compound path gave its paths.
|
||||
let g = made(&mut s, "object.group");
|
||||
let front = rect(&mut s, 20.0, 20.0, 5.0, 5.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g, front.0]})).unwrap();
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
assert_eq!(paint(&s, c), red);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap().len(), 3);
|
||||
}
|
||||
/// Compound paths joined into one keep their own holes: each one's paths turn together, so the
|
||||
/// counters of outlined letters stay open (they filled in when every path was turned alone).
|
||||
#[test]
|
||||
fn joined_compound_paths_keep_their_holes() {
|
||||
let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut s = session();
|
||||
let mut rings = vec![];
|
||||
for x in [0.0, 100.0, 200.0] {
|
||||
let (outer, inner) = (rect(&mut s, x, 0.0, 40.0, 40.0), rect(&mut s, x + 10.0, 10.0, 10.0, 10.0));
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
rings.push(made(&mut s, "object.compoundPath.make"));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": rings})).unwrap();
|
||||
made(&mut s, "object.group");
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let turns: Vec<f64> =
|
||||
d.node(NodeId(c)).unwrap().children().unwrap().iter().map(|p| p.path_data().unwrap().subpaths[0].signed_area().signum()).collect();
|
||||
assert_eq!(turns.len(), 6);
|
||||
for ring in turns.chunks(2) {
|
||||
assert_eq!(ring[0], -ring[1], "a hole runs against its outline: {turns:?}");
|
||||
}
|
||||
// The rings in front run against the backmost one (#1197).
|
||||
assert_eq!((turns[2], turns[4]), (-turns[0], -turns[0]), "{turns:?}");
|
||||
}
|
||||
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
|
||||
/// the same fields the full-tree summary carries, without serializing the whole tree.
|
||||
#[test]
|
||||
|
||||
@@ -398,6 +398,25 @@ fn new_symbol_replaces_selection_with_instance_of_same_bounds() {
|
||||
assert_eq!(s.doc().unwrap().doc.symbols.len(), 1);
|
||||
}
|
||||
|
||||
/// New Symbol given `ids` that list an object and then the group that holds it puts the instance
|
||||
/// where the group was and removes both, as removing one object at a time does.
|
||||
#[test]
|
||||
fn new_symbol_of_an_object_and_the_group_that_holds_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||||
select(&mut s, &[a, b]);
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, group.0]})).unwrap();
|
||||
let inst = NodeId(r["id"].as_u64().unwrap());
|
||||
let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [inst, c]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [inst]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_and_break_link_round_trip_geometry() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -795,7 +795,8 @@ fn image_trace_presets_query_and_errors() {
|
||||
let mut s = session();
|
||||
let r = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names.len(), 13);
|
||||
assert_eq!(names.len(), 15);
|
||||
assert!(names.contains(&"Auto Color") && names.contains(&"Outline"));
|
||||
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray") && names.contains(&"Flat Logo"));
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image");
|
||||
|
||||
@@ -412,6 +412,29 @@ fn smooth_tool_reduces_jagged_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
|
||||
}
|
||||
|
||||
/// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight
|
||||
/// sides next to the bump stay straight lines from their far ends, which keep their place.
|
||||
#[test]
|
||||
fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> =
|
||||
[(50.0, 200.0), (190.0, 200.0), (200.0, 192.0), (210.0, 200.0), (350.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y})).to_vec();
|
||||
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let before = path(&s, id);
|
||||
drag(&mut s, "smooth", &[(195.0, 194.0), (205.0, 194.0)], Mods::default());
|
||||
let p = path(&s, id);
|
||||
assert_ne!(p, before, "the bump is smoothed");
|
||||
let a = &p.subpaths[0].anchors;
|
||||
let (first, second, last) = (a[0], a[1], a[a.len() - 1]);
|
||||
assert!(first.p == Point::new(50.0, 200.0) && !first.has_out() && !second.has_in(), "the left side is a line still: {first:?} {second:?}");
|
||||
assert!(last.p == Point::new(350.0, 200.0) && !last.has_in() && !a[a.len() - 2].has_out(), "and the right one: {last:?}");
|
||||
// Every other anchor is within the tool's reach of the drag (12 px either way).
|
||||
for x in &a[1..a.len() - 1] {
|
||||
assert!((182.0..=218.0).contains(&x.p.x) && (190.0..=201.0).contains(&x.p.y), "{x:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_tool_joins_two_lines() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -310,6 +310,23 @@ fn flatten_is_one_undo_step() {
|
||||
assert!(!exists(&s, a));
|
||||
}
|
||||
|
||||
/// Flattening a document for export in which two objects have the same id (a damaged file)
|
||||
/// removes both objects and puts one group in their place.
|
||||
#[test]
|
||||
fn flattening_objects_that_share_an_id_removes_both() {
|
||||
let _settings = settings_lock();
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000");
|
||||
let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff");
|
||||
run(&mut s, "transparency.set", json!({"ids": [a, b], "opacity": 50}));
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.node_mut(NodeId(b)).unwrap().id = NodeId(a);
|
||||
let flat = crate::cmd::flatten::flatten_document(&doc, &crate::cmd::FlattenOptions::default()).unwrap().unwrap();
|
||||
let art = flat.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "one group where the two objects were");
|
||||
assert!(matches!(art[0].kind, NodeKind::Group { .. }) && art[0].id != NodeId(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_without_transparency_stay_unless_outlined() {
|
||||
let _settings = settings_lock();
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Appearance, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, Rect, shapes};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -112,6 +112,28 @@ fn delete_of_100000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_selection_of_100000_rows_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "layer.delete", json!({"ids": ids}));
|
||||
assert_eq!(r["deleted"], json!(100_000));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_up_of_100000_unpainted_paths_finishes_within_20_s() {
|
||||
let s = session(100_000, |d, k| Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default()));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "object.path.cleanUp", json!({}));
|
||||
assert_eq!(r["removed"], json!(100_000));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
@@ -184,6 +206,21 @@ fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.path.offsetPath", json!({"offset": 1}));
|
||||
// Each offset path goes directly above its square and takes the next id, in the squares' order.
|
||||
let offsets = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == offsets);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &offsets), "each offset path directly above its square");
|
||||
assert!(selected(&s, &offsets));
|
||||
let grown = bounds(&s, offsets[1]);
|
||||
assert!((grown.width() - 12.0).abs() < 1e-6 && (grown.x0 - 19.0).abs() < 1e-6, "{grown:?}");
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
@@ -211,6 +248,34 @@ fn send_to_current_layer_of_100000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_drag_of_100000_rows_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let ids = layer(&s, 0);
|
||||
let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "layer.move", json!({"ids": ids, "target": to}));
|
||||
assert!(ids_in(&r) == ids);
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(children(&s, to) == ids, "the layer holds the rows in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_selected_of_100000_objects_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let (from, ids) = (s.doc().unwrap().doc.layers[0].id, layer(&s, 0));
|
||||
let into = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "layer.merge", json!({"ids": [from, into]}));
|
||||
assert_eq!(r["id"], json!(into));
|
||||
assert!(s.doc().unwrap().doc.node(from).is_none(), "the emptied layer goes");
|
||||
assert!(children(&s, into) == ids, "the layer holds the objects in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungroup_of_25000_groups_finishes_within_20_s() {
|
||||
// Each group holds two squares, and ungrouping one adds an object to the layer.
|
||||
@@ -240,6 +305,45 @@ fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_to_layers_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let (before, l, ids, first) = (doc.clone(), doc.layers[0].id, layer(&s, 0), doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "layer.releaseToLayers", json!({"id": l}));
|
||||
// Each object goes into a sublayer of its own, in their order. The sublayers take the next ids,
|
||||
// and with Layer 1 counted, sublayer k is named `Layer k + 2` and takes layer color k + 1,
|
||||
// wrapping around after the last preset.
|
||||
let subs = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == subs);
|
||||
assert!(layer(&s, 0) == subs);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
for (k, (sub, id)) in subs.iter().zip(&ids).enumerate() {
|
||||
let n = doc.node(*sub).unwrap();
|
||||
assert_eq!(ids_of(n), [*id]);
|
||||
assert_eq!(n.name.as_deref(), Some(format!("Layer {}", k + 2).as_str()));
|
||||
assert!(matches!(n.kind, NodeKind::Layer { color: LayerColor::Preset(c), .. } if usize::from(c) == (k + 1) % LAYER_COLORS.len()));
|
||||
}
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_25000_objects_finishes_within_20_s() {
|
||||
let s = session(25_000, square);
|
||||
let (before, first) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.path.outlineStroke", json!({}));
|
||||
// Each square, filled and stroked, becomes a group of its fill and the outline of its stroke,
|
||||
// where the square was, and the groups take new ids in the squares' order.
|
||||
let groups = ids_in(&r);
|
||||
assert_eq!(groups.len(), 25_000);
|
||||
assert!(layer(&s, 0) == groups, "each group where its square was");
|
||||
assert!(groups[0].0 >= first && groups.windows(2).all(|w| w[0] < w[1]));
|
||||
assert!(groups.iter().all(|g| children(&s, *g).len() == 2));
|
||||
assert!(selected(&s, &groups));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// With Alt held, a Selection tool press on one of 30,000 selected squares, four moves of 20 pt
|
||||
/// and the release copy the squares once, as one undo step.
|
||||
#[test]
|
||||
@@ -339,6 +443,193 @@ fn group_delete_and_duplicate_across_layers_and_inside_a_group_keep_order_and_id
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// After the Layers panel's Delete Selection, Duplicate Selection, row drags, Collect in New Layer
|
||||
/// and Merge Selected on rows in two layers and inside a group, the layers hold the rows and the
|
||||
/// new ones in the order, and with the ids, that each check lists, and Undo gives back the
|
||||
/// document.
|
||||
#[test]
|
||||
fn layers_panel_rows_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (n, l1, l2) = (before.peek_next_id(), before.layers[0].id.0, before.layers[1].id.0);
|
||||
|
||||
let r = s.execute("layer.delete", &json!({"ids": [a, e, h]})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([g])]);
|
||||
assert!(selected(&s, &[NodeId(c), NodeId(g)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Each copy goes directly above its row and takes the next id; a layer's copy is renamed.
|
||||
let r = s.execute("layer.duplicate", &json!({"ids": [a, e, h]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, n, b, c, [group, d, e, n + 1, f]]), json!([g, h, n + 2])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
let r = s.execute("layer.duplicate", &json!({"ids": [l2]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, h]), json!([n + 1, n + 2])]);
|
||||
assert_eq!(s.doc().unwrap().doc.layers[2].name.as_deref(), Some("Layer 2 copy"));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Rows are dragged below c, then above d in the group, and copies of rows are dragged above g.
|
||||
let r = s.execute("layer.move", &json!({"ids": [a, e, h], "target": c, "place": "below"})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a, e, h]));
|
||||
assert_eq!(layers(&s), [json!([b, a, e, h, c, [group, d, f]]), json!([g])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
s.execute("layer.move", &json!({"ids": [a, g], "target": d, "place": "above"})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, a, g, e, f]]), json!([h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
let r = s.execute("layer.move", &json!({"ids": [a, e], "target": g, "place": "above", "copy": true})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, n, n + 1, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// The new layer goes where the topmost row was when that row is in a layer, and on top of the
|
||||
// layer holding the topmost row's group when it is in a group.
|
||||
assert_eq!(s.execute("layer.collectInNew", &json!({"ids": [a, e, g]})).unwrap()["id"], json!(n));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([[n, a, e, g], h])]);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(n)).unwrap().name.as_deref(), Some("Layer 3"));
|
||||
undo_gives_back(&mut s, &before);
|
||||
assert_eq!(s.execute("layer.collectInNew", &json!({"ids": [a, e]})).unwrap()["id"], json!(n));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f], [n, a, e]]), json!([g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Rows merge into the layer listed last, in the stacking order, and so does a merged layer's
|
||||
// art.
|
||||
s.execute("layer.merge", &json!({"ids": [e, g, l1]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, f], e, g]), json!([h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
s.execute("layer.merge", &json!({"ids": [l1, l2]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f], g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// Release to Layers (Sequence on a layer and on a group, Build on a layer) and Flatten Artwork
|
||||
/// leave the layers, sublayers and objects in the order, and with the ids and names, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn release_to_layers_and_flatten_artwork_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let l2 = s.doc().unwrap().doc.layers[1].id.0;
|
||||
// Layer 2 also holds a sublayer, on top of g and h.
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": l2})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (n, l1) = (before.peek_next_id(), before.layers[0].id.0);
|
||||
let names = |s: &Session, r: &Value| ids_in(r).iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().display_name()).collect::<Vec<_>>();
|
||||
|
||||
// A layer's objects go into sublayers of their own, below its sublayers.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": l2})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, g], [n + 1, h], [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Build: a sublayer holds copies of the objects below its own and a copy of its own, and the
|
||||
// top one holds the objects.
|
||||
let r = s.execute("layer.releaseToLayersBuild", &json!({"id": l2})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, n + 1], [n + 2, g, h], [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// A group's objects go into sublayers of its layer, where the group was.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": group})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [n, d], [n + 1, e], [n + 2, f]]), json!([g, h, [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5", "Layer 6"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Flatten Artwork into Layer 1 deletes the two hidden layers and keeps the template layer.
|
||||
for p in [json!({"top": true, "visible": false}), json!({"top": true, "visible": false}), json!({"top": true, "template": true})] {
|
||||
s.execute("layer.new", &p).unwrap();
|
||||
}
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(s.execute("layer.flatten", &json!({"id": l1})).unwrap()["discarded"], 2);
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f], g, h, [sub]]), json!([])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// Duplicate Artboard and `artboard.move` with `copy` and `moveArt` (the Artboard tool's Alt-drag
|
||||
/// with Move/Copy Artwork with Artboard on) put a copy of each object on the artboard directly
|
||||
/// above it, moved with the artboard's copy, and Cut Artboard removes those objects. An object off
|
||||
/// the artboard stays. The layers then hold the objects in the order, and with the ids, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn artboard_copies_and_cut_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let off = s.execute("shape.rectangle", &json!({"x": 900, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
let copies = json!([n, n + 1, n + 2, n + 3, n + 7, n + 8]);
|
||||
let copied = [json!([a, n, b, n + 1, c, n + 2, [group, d, e, f], [n + 3, n + 4, n + 5, n + 6]]), json!([g, n + 7, h, n + 8, off])];
|
||||
// The copy of h, the last object on the artboard, sits where h sits, moved by `dx`.
|
||||
let moved_by = |s: &Session, dx: f64| bounds(s, NodeId(n + 8)) == bounds(s, NodeId(h)) + vectorcraft_geom::Vec2::new(dx, 0.0);
|
||||
|
||||
// Duplicate Artboard places the copy 20 pt right of the last artboard.
|
||||
let right = before.artboards.iter().map(|ab| ab.rect.x1).fold(f64::MIN, f64::max);
|
||||
assert_eq!(s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"], copies);
|
||||
assert_eq!(layers(&s), copied);
|
||||
assert!(moved_by(&s, right + 20.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
let r = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap();
|
||||
assert_eq!(r["moved"], copies);
|
||||
assert_eq!(layers(&s), copied);
|
||||
assert!(moved_by(&s, 900.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
assert_eq!(s.execute("artboard.cut", &json!({"index": 0, "art": true})).unwrap()["copied"], 6);
|
||||
assert_eq!(layers(&s), [json!([]), json!([off])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// After Offset Path, Outline Stroke, Unite and Clean Up of objects in two layers and inside a
|
||||
/// group, the layers hold the objects and the new ones in the order, and with the ids, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn path_commands_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
|
||||
let r = s.execute("object.path.offsetPath", &json!({"offset": 1})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2, n + 3, n + 4]));
|
||||
assert_eq!(layers(&s), [json!([a, n, b, c, n + 1, [group, d, e, n + 2, f]]), json!([g, n + 3, h, n + 4])]);
|
||||
assert!(selected(&s, &ids_from(n, 5)));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Each object becomes a group of its fill and the outline of its stroke, where it was.
|
||||
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
|
||||
let [a2, c2, e2, g2, h2] = <[NodeId; 5]>::try_from(ids_in(&r)).unwrap();
|
||||
assert!(n <= a2.0 && a2 < c2 && c2 < e2 && e2 < g2 && g2 < h2);
|
||||
assert_eq!(layer(&s, 0), [a2, NodeId(b), c2, NodeId(group)]);
|
||||
assert_eq!(children(&s, NodeId(group)), [NodeId(d), e2, NodeId(f)]);
|
||||
assert_eq!(layer(&s, 1), [g2, h2]);
|
||||
assert!(selected(&s, &[a2, c2, e2, g2, h2]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// The result goes where the topmost object, h, was.
|
||||
let r = s.execute("object.pathfinder.unite", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n]));
|
||||
assert_eq!(layers(&s)[0], json!([b, [group, d, f]]));
|
||||
assert_eq!(layer(&s, 1), [NodeId(n)]);
|
||||
assert!(selected(&s, &[NodeId(n)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
s.edit("Paint nothing", |d, _| {
|
||||
for id in [a, e, h] {
|
||||
d.node_mut(NodeId(id)).unwrap().appearance = Appearance::default().into();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("object.path.cleanUp", &json!({})).unwrap();
|
||||
assert_eq!(r["removed"], json!(3));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([g])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// `object.transform` copies ids listed twice, and an object listed before a group around it.
|
||||
/// Each copy goes directly above its original, a later copy of the same object goes below an
|
||||
/// earlier one, and the copy of the group holds a copy of the copy made inside the group before it.
|
||||
|
||||
@@ -17,6 +17,38 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
|
||||
/// placing and listing read).
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
|
||||
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
|
||||
out.extend((chunk.len() as u32 - 4).to_be_bytes());
|
||||
out.extend(chunk);
|
||||
out.extend(crc32fast::hash(chunk).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An image past what a decode may take isn't drawn: Place and Open say so and the Links panel's
|
||||
/// row carries the note, where the image used to be blank with no word (#1479).
|
||||
#[test]
|
||||
fn an_image_too_large_to_draw_is_reported() {
|
||||
const NOTE: &str = "an image of 200000 × 200000 pixels is too large to draw";
|
||||
let dir = Folder::new("huge");
|
||||
let (big, small) = (dir.file("wall.png"), dir.file("small.png"));
|
||||
write(&big, &png_header(200_000, 200_000));
|
||||
write(&small, &png(40, 20, BLUE));
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": big, "at": [200, 150], "link": false}))["warnings"], json!([NOTE]));
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": small, "at": [20, 20], "link": false}))["warnings"], json!([]));
|
||||
let l = run(&mut s, "links.list", json!({}));
|
||||
let notes: Vec<&Value> = l["links"].as_array().unwrap().iter().map(|r| &r["note"]).collect();
|
||||
assert_eq!(notes, [&Value::Null, &json!(NOTE)], "top first: the small image has nothing to say");
|
||||
// Opened as a document: said too. An image that decodes (16,000 px square) has no note.
|
||||
assert_eq!(crate::cmd::fileio::load("wall.png", &png_header(200_000, 200_000)).unwrap().warnings, [NOTE]);
|
||||
assert!(crate::cmd::fileio::load("fine.png", &png_header(16_000, 16_000)).unwrap().warnings.is_empty());
|
||||
}
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
@@ -217,6 +217,35 @@ fn blend_release_restores_keys() {
|
||||
assert!(s.execute("object.blend.release", &json!({})).is_ok() || s.execute("object.blend.release", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Release Blend of a blend and of a blend inside a group that is a key object of the first puts
|
||||
/// the outer blend's spine and keys where it was, then the inner blend's spine and keys where it
|
||||
/// was inside that group, as releasing one blend at a time does.
|
||||
#[test]
|
||||
fn blend_release_of_a_blend_and_a_blend_inside_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (r1, r2) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||||
sel(&mut s, &[r1, r2]);
|
||||
let inner = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
let r3 = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[inner, r3]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let r4 = rect(&mut s, 400.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[group, r4]);
|
||||
let outer = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
sel(&mut s, &[outer, inner]);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([group.0, r4.0, r1.0, r2.0]));
|
||||
assert_eq!(r["spines"], json!([n, n + 1]));
|
||||
let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [NodeId(n), group, r4]);
|
||||
assert_eq!(ids(&node(&s, group)), [NodeId(n + 1), r1, r2, r3]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(n), NodeId(n + 1), group, r4, r1, r2]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_expand_makes_group_with_unique_ids() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1826,3 +1826,15 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
|
||||
assert_eq!(bounds(&s, img), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_trim_leaves_a_cmyk_image_alone() {
|
||||
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [255, 0, 0, 0].repeat(8)).unwrap();
|
||||
let tiff = vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "inks.tif", "dataBase64": vectorcraft_format::base64_encode(&tiff.bytes)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), (Some(false), Some(4), Some(2)), "{r}");
|
||||
}
|
||||
@@ -152,6 +152,30 @@ fn release_while_editing_leaves_editing_first() {
|
||||
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
|
||||
}
|
||||
|
||||
/// Releasing the masks of a group and of an object inside it puts each mask's art directly above
|
||||
/// its object, as releasing one mask at a time does.
|
||||
#[test]
|
||||
fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (a, m1) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 0.0, 50.0));
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m1.0]})).unwrap();
|
||||
let b = rect(&mut s, 150.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let m2 = rect(&mut s, 0.0, 300.0);
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [group.0, m2.0]})).unwrap();
|
||||
let before = doc(&s).clone();
|
||||
let n = before.peek_next_id();
|
||||
s.execute("transparency.releaseOpacityMask", &json!({"ids": [group.0, a.0]})).unwrap();
|
||||
let ids = |n: &vectorcraft_doc::Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&doc(&s).layers[0]), [group, NodeId(n)]);
|
||||
assert_eq!(ids(doc(&s).node(group).unwrap()), [a, NodeId(n + 1), b]);
|
||||
assert!(mask(&s, group).is_none() && mask(&s, a).is_none());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [group, NodeId(n), a, NodeId(n + 1)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*doc(&s) == before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1290,6 +1290,13 @@ proptest! {
|
||||
survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// A damaged EPS another app copied: Paste reads it as an error or as objects.
|
||||
#[test]
|
||||
fn pasted_eps_never_panics(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::<u8>()), 0..12)) {
|
||||
static SAMPLE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
paste_from_elsewhere("clipboard.importEps", json!({"dataBase64": mutated_b64(SAMPLE.get_or_init(foreign_eps_sample).clone(), cut, &edits)}))?;
|
||||
}
|
||||
|
||||
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
|
||||
/// as files in the legacy Illustrator format too (the creator line is what turns on its
|
||||
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which
|
||||
|
||||
@@ -158,8 +158,11 @@ impl GradientDef {
|
||||
if self.gradient.stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Stops come last first: reversed, stops sharing a ramp point keep their order.
|
||||
self.gradient.stops.reverse();
|
||||
// Stops are listed along the ramp from either end: listed downwards they're reversed, so
|
||||
// stops sharing a ramp point keep their order (and each its midpoint, to the next stop).
|
||||
if self.gradient.stops.first().zip(self.gradient.stops.last()).is_some_and(|(first, last)| first.offset > last.offset) {
|
||||
self.gradient.stops.reverse();
|
||||
}
|
||||
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Some((self.name, self.gradient))
|
||||
}
|
||||
|
||||
@@ -410,6 +410,19 @@ fn stops_sharing_a_ramp_point_keep_their_order() {
|
||||
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_listed_upwards_keep_the_midpoint_of_the_one_that_starts_the_ramp() {
|
||||
// GUARDS: reversing a list that runs upwards swaps two stops at 0, and the ramp to 100 takes the
|
||||
// midpoint of the zero-length part between them.
|
||||
let defs = "%AI5_BeginGradient: (U)\n(U) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n\
|
||||
%_0 0 0 0 1 1 1 2 1 6 87 0 Bs\n%_0 0 0 0 0 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!("{}Bb\n1 (U) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
|
||||
let midpoints: Vec<_> = g.gradient.stops.iter().map(|s| s.midpoint).collect();
|
||||
assert_eq!(midpoints, [0.5, 0.87, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_anchor_data_reads() {
|
||||
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
|
||||
@@ -243,13 +243,32 @@ impl SaveOptions {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`]).
|
||||
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`] and
|
||||
/// [`PRETTY_MAX_BYTES`]).
|
||||
pub fn for_doc(doc: &Document) -> Self {
|
||||
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
|
||||
Self { pretty: objects <= PRETTY_MAX_OBJECTS, ..Self::default() }
|
||||
Self { pretty: objects <= PRETTY_MAX_OBJECTS && compact_size_at_most(doc, PRETTY_MAX_BYTES), ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `doc` at most `limit` bytes as compact JSON? Serializing stops as soon as it is past the
|
||||
/// limit, so a large document costs no more than `limit` bytes of work.
|
||||
fn compact_size_at_most(doc: &Document, limit: usize) -> bool {
|
||||
struct Capped {
|
||||
left: usize,
|
||||
}
|
||||
impl Write for Capped {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.left = self.left.checked_sub(buf.len()).ok_or_else(|| std::io::Error::other("over the limit"))?;
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
serde_json::to_writer(Capped { left: limit }, doc).is_ok()
|
||||
}
|
||||
|
||||
/// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working
|
||||
/// copies are left out ([`Document::without_edit_modes`]).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
@@ -260,6 +279,11 @@ pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
/// compact, where indentation would multiply the file size and slow opening down.
|
||||
pub const PRETTY_MAX_OBJECTS: usize = 2000;
|
||||
|
||||
/// Documents whose compact JSON is larger than this are saved compact even with few objects:
|
||||
/// pretty-printing spreads every path anchor over several lines, so traced or generated art (in
|
||||
/// layers, symbols or patterns alike) would grow about tenfold.
|
||||
pub const PRETTY_MAX_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Serialize for saving to disk: pretty when small, compact when large.
|
||||
pub fn save_file(doc: &Document) -> Vec<u8> {
|
||||
save_with(doc, &SaveOptions::for_doc(doc)).unwrap_or_default()
|
||||
|
||||
@@ -8,9 +8,11 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::links::LinkInfo;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_format::{ICC_MIME, SaveOptions, load, load_file, pdf_content, save_with};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind, Symbol};
|
||||
use vectorcraft_format::{ICC_MIME, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with};
|
||||
use vectorcraft_geom::path::{PathData, SubPath};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||
|
||||
fn json_of(bytes: &[u8]) -> Value {
|
||||
serde_json::from_slice(bytes).unwrap()
|
||||
@@ -82,3 +84,66 @@ fn embedded_profiles_and_pdf_round_trip() {
|
||||
let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1);
|
||||
assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty());
|
||||
}
|
||||
|
||||
/// A document of `paths` open paths with `anchors` anchors each.
|
||||
fn paths_doc(paths: usize, anchors: usize) -> Document {
|
||||
let mut b = DocBuilder::new(1000.0, 1000.0);
|
||||
for i in 0..paths {
|
||||
let points: Vec<Point> = (0..anchors).map(|j| Point::new(j as f64, i as f64 + (j % 2) as f64)).collect();
|
||||
let node = b.path_node(PathData::single(SubPath::polyline(&points, false)), Default::default());
|
||||
b.add(node);
|
||||
}
|
||||
b.doc
|
||||
}
|
||||
|
||||
fn is_pretty(bytes: &[u8]) -> bool {
|
||||
bytes.windows(3).any(|w| w == b"\n ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_documents_are_saved_pretty() {
|
||||
let d = paths_doc(10, 10);
|
||||
assert!(SaveOptions::for_doc(&d).pretty);
|
||||
assert!(is_pretty(&save_file(&d)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_objects_are_saved_compact() {
|
||||
let d = paths_doc(PRETTY_MAX_OBJECTS + 1, 2);
|
||||
assert!(!SaveOptions::for_doc(&d).pretty);
|
||||
}
|
||||
|
||||
/// Anchors per path that make a document of `paths` paths about `bytes` of compact JSON.
|
||||
fn anchors_for(paths: usize, bytes: usize) -> usize {
|
||||
let per_anchor = serde_json::to_vec(&paths_doc(1, 1000)).unwrap().len() / 1000;
|
||||
bytes / per_anchor / paths
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn few_objects_with_many_anchors_are_saved_compact() {
|
||||
let d = paths_doc(3, anchors_for(3, 2 * PRETTY_MAX_BYTES));
|
||||
assert!(!SaveOptions::for_doc(&d).pretty, "3 objects, but about {} bytes", 2 * PRETTY_MAX_BYTES);
|
||||
let bytes = save_file(&d);
|
||||
assert!(!is_pretty(&bytes));
|
||||
assert_eq!(load(&bytes).unwrap().layers[0].count(), d.layers[0].count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_under_the_size_limit_stay_pretty() {
|
||||
let d = paths_doc(2, anchors_for(2, PRETTY_MAX_BYTES / 2));
|
||||
assert!(SaveOptions::for_doc(&d).pretty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_outside_the_layers_counts_too() {
|
||||
// Generated art kept in a symbol: one instance-sized document, a symbol full of anchors.
|
||||
let mut d = paths_doc(1, 4);
|
||||
let mut art = paths_doc(1, anchors_for(1, 2 * PRETTY_MAX_BYTES));
|
||||
let path = Arc::clone(&art.layers[0]);
|
||||
art.layers.clear();
|
||||
d.symbols.push(Symbol { name: "Leaves".into(), art: path });
|
||||
assert!(!SaveOptions::for_doc(&d).pretty);
|
||||
let bytes = save_file(&d);
|
||||
assert!(!is_pretty(&bytes));
|
||||
assert_eq!(load(&bytes).unwrap().symbols.len(), 1);
|
||||
}
|
||||
@@ -155,8 +155,8 @@ impl Corner {
|
||||
let (h_out, h_in) = match kind {
|
||||
// An arc tangent to both sides, turning by 180° less the angle between them there.
|
||||
CornerKind::Round => {
|
||||
let h = radius * arc_handle(-cos);
|
||||
(start - du * h, end - dv * h)
|
||||
let (hu, hv) = self.round_handles(radius, (start, du), (end, dv));
|
||||
(start - du * hu, end - dv * hv)
|
||||
}
|
||||
// An arc centred on the corner through both ends, spanning the angle.
|
||||
CornerKind::InvertedRound => {
|
||||
@@ -169,6 +169,33 @@ impl Corner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// The handle lengths of a round cut of `radius` between `start` and `end` (each a point and
|
||||
/// the direction its side runs on in there). Between straight sides they are the arc's. A
|
||||
/// curved side has turned by the time the cut meets it, so the arc's handles would reach past
|
||||
/// where the tangents at the cut's ends cross and the cut would stick out of the corner
|
||||
/// (#1512): there each handle is the share an arc takes of its tangent, up to that crossing
|
||||
/// (a third of the way to the other end where the tangents don't cross between them).
|
||||
fn round_handles(&self, radius: f64, (start, du): (Point, Vec2), (end, dv): (Point, Vec2)) -> (f64, f64) {
|
||||
let cos = du.dot(dv);
|
||||
let arc = arc_handle(-cos);
|
||||
if self.side_u.straight() && self.side_v.straight() {
|
||||
return (radius * arc, radius * arc);
|
||||
}
|
||||
// start − du·a = end − dv·b is where the tangents cross.
|
||||
let (cross, w) = (du.cross(dv), start - end);
|
||||
let (a, b) = (w.cross(dv) / cross, w.cross(du) / cross);
|
||||
if cross.abs() > MIN_SINE && a > EPS && b > EPS && a.is_finite() && b.is_finite() {
|
||||
// An arc's handle over its tangent's length, radius · tan(turn / 2).
|
||||
let share = arc * (1.0 - cos) / cross.abs();
|
||||
(a * share, b * share)
|
||||
} else {
|
||||
let third = start.distance(end) / 3.0;
|
||||
(third, third)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
|
||||
/// and the handles between them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -401,6 +428,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A round cut next to a curve stays inside its corner, however large (#1512): its handles
|
||||
/// stop short of where the tangents at its ends cross, so it doesn't stick out past the sides.
|
||||
#[test]
|
||||
fn a_round_cut_next_to_a_curve_stays_inside_its_corner() {
|
||||
// A square whose top side arches up, meeting the upright sides at an angle.
|
||||
let mut sp =
|
||||
SubPath::polyline(&[Point::new(100.0, 150.0), Point::new(300.0, 150.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)], true);
|
||||
sp.anchors[0].h_out = Point::new(120.0, 20.0);
|
||||
sp.anchors[1].h_in = Point::new(280.0, 20.0);
|
||||
let path = PathData::single(sp);
|
||||
let bounds = path.bounds().unwrap().inflate(1e-6, 1e-6);
|
||||
for radius in [10.0, 60.0, 200.0, 1e6] {
|
||||
let (cut, _) = cut_corners(&path, &[radius, radius], &[]);
|
||||
let b = cut.bounds().unwrap();
|
||||
assert!(bounds.union(b) == bounds, "radius {radius}: {b:?} is outside {bounds:?}");
|
||||
// Each cut's handles end before its tangents cross: the cut turns one way only.
|
||||
let a = &cut.subpaths[0].anchors;
|
||||
for (from, to) in [(a[a.len() - 1], a[0]), (a[1], a[2])] {
|
||||
let (t0, t1, chord) = (from.h_out - from.p, to.p - to.h_in, to.p - from.p);
|
||||
assert!(t0.cross(chord) * chord.cross(t1) > 0.0 && t0.cross(t1) * t0.cross(chord) > 0.0, "radius {radius}: {from:?} {to:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverted round and chamfer cuts end where the round one does, at any angle.
|
||||
#[test]
|
||||
fn kinds_share_their_ends() {
|
||||
|
||||
@@ -197,6 +197,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data);
|
||||
let cache = InterpreterCache::new();
|
||||
let mut x = 0.0;
|
||||
// Where the next page's art may begin (a `.ai` page's art reaches past its artboard).
|
||||
let mut clear = f64::NEG_INFINITY;
|
||||
// Every page only text over private data: a file saved without its PDF part shows a notice
|
||||
// there, not its art. A page that states where its art is isn't that notice.
|
||||
let (mut only_text, mut art_boxes) = (true, false);
|
||||
@@ -220,6 +222,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
|
||||
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
|
||||
b.page = ab;
|
||||
// The first id of this page's art.
|
||||
let first_id = b.next;
|
||||
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
|
||||
if route {
|
||||
let mut seen = HashSet::new();
|
||||
@@ -267,6 +271,33 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
if clipped {
|
||||
b.warn(PAST_PAGE_NOTE);
|
||||
}
|
||||
// A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art
|
||||
// reaches far to the left would lay it over the page before it in the row: there it would
|
||||
// be taken for that page's art (by the check against the editing data, which then finds
|
||||
// art its layers don't have there, and by `place_pages`). Such a page moves right until its
|
||||
// art clears the art before it.
|
||||
let mut art_right = f64::NEG_INFINITY;
|
||||
if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) {
|
||||
// Art at a coordinate that isn't a number (a damaged file) moves nothing.
|
||||
let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0);
|
||||
if dx > 0.0 {
|
||||
let shift = Affine::translate((dx, 0.0));
|
||||
for (_, v) in &mut parts {
|
||||
for n in v.iter_mut() {
|
||||
Arc::make_mut(n).transform(shift, false);
|
||||
}
|
||||
}
|
||||
// What the page's lines of type were is told where their glyphs are (the outlines
|
||||
// stroked on them are looked for there), so that moves with them.
|
||||
for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) {
|
||||
for ink in &mut facts.ink {
|
||||
*ink = *ink + kurbo::Vec2::new(dx, 0.0);
|
||||
}
|
||||
}
|
||||
ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1);
|
||||
}
|
||||
art_right = art.x1 + dx;
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
only_text &= ai && is_only_text(&children);
|
||||
art_boxes |= crate::pages::states_art(page);
|
||||
@@ -278,6 +309,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
ab = art;
|
||||
}
|
||||
x = right + opts.artboard_gap;
|
||||
clear = right.max(art_right) + opts.artboard_gap;
|
||||
doc.artboards.push(Artboard {
|
||||
id: i as u32 + 1,
|
||||
name: format!("Artboard {}", i + 1),
|
||||
|
||||
@@ -131,6 +131,28 @@ fn a_stroke_written_as_stroked_glyph_outlines_is_the_types_stroke() {
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A `.ai` page moved right in the row, so its art past the artboard clears the page before it
|
||||
/// (#1528), still has the stroke drawn on its type's glyphs read as the type's stroke.
|
||||
#[test]
|
||||
fn a_stroke_on_the_type_of_a_moved_ai_page_is_still_the_types_stroke() {
|
||||
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
|
||||
let content = format!(
|
||||
"0 g -70 10 80 20 re f BT 1 0 0 rg /F1 24 Tf 10 50 Td (Hi) Tj ET 0 0 1 RG 0.5 w {}{}",
|
||||
outline('H', 10.0),
|
||||
outline('i', 10.0 + 0.722 * 24.0)
|
||||
);
|
||||
let pages = [("0 g 10 10 20 20 re f", String::new()), (content.as_str(), HELVETICA.to_string())].map(|(c, resources)| PdfPage {
|
||||
entries: ai.into(),
|
||||
resources,
|
||||
..PdfPage::new(100.0, 100.0, c)
|
||||
});
|
||||
let d = import(&vectorcraft_testkit::pdf::pdf(&pages, None)).unwrap();
|
||||
assert!(d.artboards[1].rect.x0 > d.artboards[0].rect.x1 + 36.0 + 1.0, "page 2 moved: {:?}", d.artboards);
|
||||
let art = d.layers[1].children().unwrap();
|
||||
assert_eq!(art.len(), 2, "the note and the type: {art:?}");
|
||||
assert!(matches!(art[1].appearance.items.as_slice(), [AppearanceItem::Stroke(_)]), "{:?}", art[1].appearance);
|
||||
}
|
||||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3.
|
||||
fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec<u8> {
|
||||
crate::tests_live_text::text_pdf(lines, false)
|
||||
|
||||
@@ -158,6 +158,27 @@ fn art_of_neighbouring_artboards_off_a_page_is_left_to_its_own_page() {
|
||||
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 2);
|
||||
}
|
||||
|
||||
/// A `.ai` keeps its art past the artboard edge (#646): a note mostly left of artboard 2 (as notes
|
||||
/// for a design are put beside it) doesn't lie over artboard 1 in the row, where it would be taken
|
||||
/// for that page's art: page 2 moves right until its art clears page 1's.
|
||||
#[test]
|
||||
fn art_of_an_ai_page_reaching_left_does_not_lie_over_the_page_before() {
|
||||
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
|
||||
let page = |content: &str| PdfPage { entries: ai.into(), ..PdfPage::new(100.0, 100.0, content) };
|
||||
// Page 2's note runs from 70 points left of its box to 10 points into it.
|
||||
let pages = [page("0 g 10 10 20 20 re f"), page("1 1 1 rg 1 0 0 RG -70 10 80 60 re B")];
|
||||
let d = open(&pdf(&pages, None), ImportOptions::default());
|
||||
let (a, b) = (d.artboards[0].rect, d.artboards[1].rect);
|
||||
let note = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!(note.x0 >= a.x1 + 36.0, "the note clears artboard 1: {note:?} {a:?}");
|
||||
assert!((b.x0 - note.x0 - 70.0).abs() < 1.0, "and keeps its place by artboard 2: {note:?} {b:?}");
|
||||
assert_eq!((b.width(), b.height()), (100.0, 100.0));
|
||||
// A plain PDF clips its art to the page: its pages stay 36 points apart.
|
||||
let plain = [PdfPage::new(100.0, 100.0, "0 g 10 10 20 20 re f"), PdfPage::new(100.0, 100.0, "1 0 0 RG -70 10 80 60 re S")];
|
||||
let d = open(&pdf(&plain, None), ImportOptions::default());
|
||||
assert_eq!(d.artboards[1].rect.x0, d.artboards[0].rect.x1 + 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_pdf_opens_with_its_password_only() {
|
||||
let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret"));
|
||||
|
||||
@@ -16,6 +16,7 @@ mod ink;
|
||||
mod live;
|
||||
mod lru;
|
||||
mod paint;
|
||||
pub use paint::decode_failures;
|
||||
mod pattern;
|
||||
pub mod placed_document;
|
||||
pub mod proof;
|
||||
|
||||
@@ -52,14 +52,53 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap.
|
||||
/// The most memory decoding one image may take: print rasters run to gigabytes (a 300 ppi roll,
|
||||
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
|
||||
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Images that couldn't be decoded (and so drew nothing), counted for exports to report.
|
||||
static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// How many images have failed to decode so far (process-wide): an export compares the count
|
||||
/// before and after to say it left images out.
|
||||
pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
|
||||
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
r.limits(limits);
|
||||
r.decode().ok()
|
||||
});
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
|
||||
fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let max = u16::MAX as u32;
|
||||
if w <= max && h <= max {
|
||||
return img;
|
||||
}
|
||||
let s = max as f64 / w.max(h) as f64;
|
||||
let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max));
|
||||
img.resize_exact(nw, nh, image::imageops::FilterType::Triangle)
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (shrunk to fit a pixmap's sides).
|
||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
|
||||
pixmap_of(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
pixmap_of(fit_pixmap(decode(bytes)?).to_rgba8())
|
||||
}
|
||||
|
||||
/// [`decode_pixmap`] shrunk to `w`×`h` pixels when the image is larger (never enlarged).
|
||||
pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixmap> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let img = decode(bytes)?;
|
||||
let img = if img.width() > w as u32 || img.height() > h as u32 {
|
||||
img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
|
||||
@@ -65,13 +65,15 @@ fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels);
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate draw on the canvas exactly as they export, filling more
|
||||
/// than the flat square with faces of differing shades (Rotate: one shade, narrower).
|
||||
/// Extrude & Bevel, Inflate (also twisted, tapered and hollow) and Rotate draw on the canvas exactly
|
||||
/// as they export, filling more than the flat square with faces of differing shades (Rotate: one
|
||||
/// shade, narrower).
|
||||
#[test]
|
||||
fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() {
|
||||
for (effect, params) in [
|
||||
("threeD.extrude", json!({"depth": 30, "bevel": "rounded"})),
|
||||
("threeD.inflate", json!({"depth": 10})),
|
||||
("threeD.inflate", json!({"depth": 10, "twist": 90, "taper": 60, "cap": false, "rotationX": -20, "rotationY": 35})),
|
||||
("threeD.rotate", json!({"rotationX": 0, "rotationY": 55, "rotationZ": 0})),
|
||||
] {
|
||||
let mut n = Node::path(
|
||||
|
||||
@@ -468,10 +468,11 @@ impl Ctx {
|
||||
None => {
|
||||
let id = href(tp)?.trim().strip_prefix('#')?;
|
||||
let el = xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))?;
|
||||
if el.tag_name().name() != "path" {
|
||||
return None;
|
||||
}
|
||||
let mut bp = BezPath::from_svg(el.attribute("d")?).ok()?;
|
||||
let d = match el.tag_name().name() {
|
||||
"path" => el.attribute("d")?.to_string(),
|
||||
_ => self.shape_path(el)?,
|
||||
};
|
||||
let mut bp = BezPath::from_svg(&d).ok()?;
|
||||
bp.apply_affine(parse_transform(el.attribute("transform")));
|
||||
bp
|
||||
}
|
||||
@@ -483,6 +484,82 @@ impl Ctx {
|
||||
usable.then_some(bp)
|
||||
}
|
||||
|
||||
/// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a
|
||||
/// `<textPath>` that points at it follows. Circles and ellipses start at 3 o'clock and rounded
|
||||
/// rects at the end of the top-left corner, all running clockwise; lines and polylines follow
|
||||
/// their points. `None` for other elements and for shapes that render nothing.
|
||||
fn shape_path(&self, el: XNode) -> Option<String> {
|
||||
let (vw, vh) = self.viewport;
|
||||
let len = |a: &str, pct: f64| el.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct));
|
||||
let x = |a: &str| len(a, vw).unwrap_or(0.0);
|
||||
let y = |a: &str| len(a, vh).unwrap_or(0.0);
|
||||
let points = || -> Vec<f64> { el.attribute("points").map(|v| list(v).map_while(|t| t.parse().ok()).collect()).unwrap_or_default() };
|
||||
let ellipse = |cx: f64, cy: f64, rx: f64, ry: f64| {
|
||||
(rx > 0.0 && ry > 0.0).then(|| {
|
||||
format!(
|
||||
"M{} {} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy}Z",
|
||||
cx + rx,
|
||||
cy,
|
||||
cy + ry,
|
||||
cx - rx,
|
||||
cy - ry,
|
||||
cx + rx
|
||||
)
|
||||
})
|
||||
};
|
||||
match el.tag_name().name() {
|
||||
"circle" => {
|
||||
let r = len("r", ((vw * vw + vh * vh) / 2.0).sqrt())?;
|
||||
ellipse(x("cx"), y("cy"), r, r)
|
||||
}
|
||||
"ellipse" => {
|
||||
let (rx, ry) = (len("rx", vw), len("ry", vh));
|
||||
let (rx, ry) = (rx.or(ry)?, ry.or(rx)?);
|
||||
ellipse(x("cx"), y("cy"), rx, ry)
|
||||
}
|
||||
"rect" => {
|
||||
let (x0, y0, w, h) = (x("x"), y("y"), len("width", vw)?, len("height", vh)?);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (rx, ry) = (len("rx", vw).filter(|v| *v > 0.0), len("ry", vh).filter(|v| *v > 0.0));
|
||||
let (rx, ry) = (rx.or(ry).unwrap_or(0.0).min(w / 2.0), ry.or(rx).unwrap_or(0.0).min(h / 2.0));
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
return Some(format!("M{x0} {y0} H{} V{} H{x0}Z", x0 + w, y0 + h));
|
||||
}
|
||||
let (x1, y1) = (x0 + w, y0 + h);
|
||||
Some(format!(
|
||||
"M{} {y0} H{} A{rx} {ry} 0 0 1 {x1} {} V{} A{rx} {ry} 0 0 1 {} {y1} H{} A{rx} {ry} 0 0 1 {x0} {} V{} A{rx} {ry} 0 0 1 {} {y0}Z",
|
||||
x0 + rx,
|
||||
x1 - rx,
|
||||
y0 + ry,
|
||||
y1 - ry,
|
||||
x1 - rx,
|
||||
x0 + rx,
|
||||
y1 - ry,
|
||||
y0 + ry,
|
||||
x0 + rx
|
||||
))
|
||||
}
|
||||
"line" => Some(format!("M{} {} L{} {}", x("x1"), y("y1"), x("x2"), y("y2"))),
|
||||
tag @ ("polyline" | "polygon") => {
|
||||
let pts = points();
|
||||
let mut d = String::new();
|
||||
for (i, [x, y]) in pts.as_chunks::<2>().0.iter().enumerate() {
|
||||
let _ = write!(d, "{}{x} {y} ", if i == 0 { 'M' } else { 'L' });
|
||||
}
|
||||
if d.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if tag == "polygon" {
|
||||
d.push('Z');
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `<text>`: the pending text and its `url(#…)` paints.
|
||||
fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
|
||||
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");
|
||||
|
||||
@@ -518,3 +518,68 @@ fn gradients_on_scaled_text_stay_in_place() {
|
||||
let (start, end) = (t.xf * geom.start, t.xf * geom.end);
|
||||
assert!(start.distance(Point::new(40.0, 0.0)) < 1e-6 && end.distance(Point::new(240.0, 0.0)) < 1e-6, "{start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// The start of a type-on-a-path object's path and a point a little way along it, in document
|
||||
/// space.
|
||||
fn path_start(t: &TextObject) -> (Point, Point) {
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, PathEl};
|
||||
let TextKind::OnPath { path, .. } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
let mut bp = path.to_bezpath();
|
||||
bp.apply_affine(t.xf);
|
||||
let PathEl::MoveTo(p0) = bp.elements()[0] else { panic!("{bp:?}") };
|
||||
let next = bp.segments().next().unwrap().eval(0.05);
|
||||
(p0, next)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_path_follows_basic_shapes_as_their_equivalent_paths() {
|
||||
let (d, w) = import_with_report(&svg(r##"<circle id="c" cx="150" cy="150" r="100"/>
|
||||
<ellipse id="e" cx="150" cy="150" rx="80" ry="40" transform="translate(10 0)"/>
|
||||
<rect id="r" x="20" y="30" width="100" height="60" rx="10"/>
|
||||
<rect id="s" x="20" y="30" width="100" height="60"/>
|
||||
<line id="l" x1="10" y1="200" x2="210" y2="200"/>
|
||||
<polyline id="pl" points="10,10 110,10 110,60"/>
|
||||
<polygon id="pg" points="10 10, 60 10, 35 50"/>
|
||||
<text font-size="10"><textPath xlink:href="#c">TEXT ON A CIRCLE</textPath></text>
|
||||
<text font-size="10"><textPath href="#e">Ellipse</textPath></text>
|
||||
<text font-size="10"><textPath href="#r">Rounded</textPath></text>
|
||||
<text font-size="10"><textPath href="#s">Square</textPath></text>
|
||||
<text font-size="10"><textPath href="#l">Line</textPath></text>
|
||||
<text font-size="10"><textPath href="#pl">Polyline</textPath></text>
|
||||
<text font-size="10"><textPath href="#pg">Polygon</textPath></text>"##))
|
||||
.unwrap();
|
||||
assert!(!w.iter().any(|w| w.contains("textPath")), "{w:?}");
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 7);
|
||||
assert!(t.iter().all(|t| matches!(t.kind, TextKind::OnPath { .. })), "{:?}", t.iter().map(|t| &t.kind).collect::<Vec<_>>());
|
||||
let near = |a: Point, b: Point| (a - b).hypot() < 1e-6;
|
||||
// Circle: starts at 3 o'clock and runs clockwise (down first, y pointing down), around the circle.
|
||||
let (p0, next) = path_start(t[0]);
|
||||
assert!(near(p0, Point::new(250.0, 150.0)) && next.y > p0.y && next.x < p0.x, "{p0:?} {next:?}");
|
||||
let TextKind::OnPath { path, .. } = &t[0].kind else { panic!("not on a path") };
|
||||
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
|
||||
assert!(close(b.x0, 50.0, 1e-6) && close(b.y0, 50.0, 1e-6) && close(b.x1, 250.0, 1e-6) && close(b.y1, 250.0, 1e-6), "{b:?}");
|
||||
assert!(path.is_closed());
|
||||
// Ellipse, with the shape's own transform.
|
||||
let (p0, next) = path_start(t[1]);
|
||||
assert!(near(p0, Point::new(240.0, 150.0)) && next.y > p0.y, "{p0:?} {next:?}");
|
||||
// Rounded rect: after the top-left corner, along the top edge to the right.
|
||||
let (p0, next) = path_start(t[2]);
|
||||
assert!(near(p0, Point::new(30.0, 30.0)) && next.x > p0.x && close(next.y, 30.0, 1e-9), "{p0:?} {next:?}");
|
||||
let (p0, next) = path_start(t[3]);
|
||||
assert!(near(p0, Point::new(20.0, 30.0)) && next.x > p0.x, "{p0:?} {next:?}");
|
||||
// Line, polyline and polygon follow their points.
|
||||
assert!(near(path_start(t[4]).0, Point::new(10.0, 200.0)));
|
||||
assert!(near(path_start(t[5]).0, Point::new(10.0, 10.0)));
|
||||
let TextKind::OnPath { path, .. } = &t[6].kind else { panic!("not on a path") };
|
||||
assert!(path.is_closed() && near(path_start(t[6]).0, Point::new(10.0, 10.0)));
|
||||
assert_eq!(t[0].plain_text(), "TEXT ON A CIRCLE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_path_to_an_empty_shape_still_falls_back_to_point_type() {
|
||||
let (d, w) =
|
||||
import_with_report(&svg(r##"<circle id="c" cx="5" cy="5" r="0"/><text x="5" y="10"><textPath href="#c">Zero</textPath></text>"##)).unwrap();
|
||||
assert!(matches!(only_text(&d).kind, TextKind::Point));
|
||||
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! The font folders fontconfig is set up with (Linux and the other Unix desktops): the `<dir>`
|
||||
//! entries of its configuration, `/etc/fonts/fonts.conf` and what that includes (`conf.d`, the
|
||||
//! user's own files). Distributions and packages add folders outside the usual ones there: TeX
|
||||
//! fonts under `/usr/share/texmf/fonts`, say, which `fc-list` and other apps then show (#1529).
|
||||
//!
|
||||
//! The format is fontconfig's documented one
|
||||
//! (<https://www.freedesktop.org/software/fontconfig/fontconfig-user.html>, "Configuration file
|
||||
//! format"): `<dir prefix="…">` and `<include prefix="…">` are all that is read here. The files are
|
||||
//! read as they are: fontconfig itself (a library, or `fc-list`) isn't asked.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The most configuration files read, and the largest one (they are a few kilobytes each).
|
||||
const MAX_FILES: usize = 512;
|
||||
const MAX_FILE_BYTES: u64 = 1 << 20;
|
||||
/// The most folders taken, and how deep files may include files.
|
||||
const MAX_DIRS: usize = 256;
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
/// A `<dir>` (a font folder) or an `<include>` (another configuration file, or a folder of them)
|
||||
/// with its `prefix` attribute and its text.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Entry {
|
||||
include: bool,
|
||||
prefix: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// The `<dir>` and `<include>` elements of a configuration file, in order. Anything that isn't
|
||||
/// one of them, comments included, is skipped; a file that isn't well formed gives what was read
|
||||
/// before the damage.
|
||||
fn entries(xml: &str) -> Vec<Entry> {
|
||||
let mut out = vec![];
|
||||
let mut rest = xml;
|
||||
while let Some(at) = rest.find('<') {
|
||||
rest = rest.get(at + 1..).unwrap_or_default();
|
||||
if let Some(comment) = rest.strip_prefix("!--") {
|
||||
rest = comment.split_once("-->").map_or("", |(_, after)| after);
|
||||
continue;
|
||||
}
|
||||
let Some((tag, after)) = rest.split_once('>') else { break };
|
||||
let name = tag.split_whitespace().next().unwrap_or_default();
|
||||
if !matches!(name, "dir" | "include") || tag.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let Some((text, after)) = after.split_once("</") else { break };
|
||||
rest = after;
|
||||
// No element is inside these: text with a tag in it isn't a path.
|
||||
if text.contains('<') {
|
||||
continue;
|
||||
}
|
||||
let path = text.trim().replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'");
|
||||
if !path.is_empty() {
|
||||
out.push(Entry { include: name == "include", prefix: attribute(tag, "prefix").unwrap_or_default().to_string(), path });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The value of attribute `name` in a start tag's text.
|
||||
fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let mut rest = tag;
|
||||
while let Some(at) = rest.find(name) {
|
||||
let before_ok = rest.get(..at).is_some_and(|b| b.ends_with(char::is_whitespace));
|
||||
rest = rest.get(at + name.len()..)?;
|
||||
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
|
||||
let value = value.trim_start();
|
||||
let quote = value.chars().next().filter(|q| matches!(q, '"' | '\''))?;
|
||||
let (value, _) = value.get(1..)?.split_once(quote)?;
|
||||
if before_ok {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the user's files are: the home folder and the XDG data and configuration folders.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Places {
|
||||
pub home: Option<PathBuf>,
|
||||
pub data_home: Option<PathBuf>,
|
||||
pub config_home: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Places {
|
||||
/// The path an entry of the file `from` names: `~` is the home folder, `prefix="xdg"` the XDG
|
||||
/// data folder (a `<dir>`) or configuration folder (an `<include>`), `prefix="relative"` the
|
||||
/// file's own folder, as is an `<include>` that isn't a full path. A `<dir>` relative to the
|
||||
/// folder the app was started in is none.
|
||||
fn resolve(&self, e: &Entry, from: &Path) -> Option<PathBuf> {
|
||||
// `~` alone or before a `/`: another user's home (`~name`) isn't looked up.
|
||||
if let Some(rest) = e.path.strip_prefix('~').filter(|rest| rest.is_empty() || rest.starts_with('/')) {
|
||||
return Some(self.home.as_ref()?.join(rest.trim_start_matches('/')));
|
||||
}
|
||||
let path = Path::new(&e.path);
|
||||
let base = match e.prefix.as_str() {
|
||||
"xdg" if e.include => self.config_home.as_deref(),
|
||||
"xdg" => self.data_home.as_deref(),
|
||||
_ if path.is_absolute() => return Some(path.to_path_buf()),
|
||||
"relative" => from.parent(),
|
||||
_ if e.include => from.parent(),
|
||||
_ => None,
|
||||
};
|
||||
Some(base?.join(path)).filter(|p| p.is_absolute())
|
||||
}
|
||||
}
|
||||
|
||||
/// The font folders the configuration file `root` and the files it includes list, in order, each
|
||||
/// once. Files that are missing or can't be read list none.
|
||||
pub(crate) fn font_dirs(root: &Path, places: &Places) -> Vec<PathBuf> {
|
||||
let mut read = Reader { places, files: HashSet::new(), dirs: vec![] };
|
||||
read.file(root, 0);
|
||||
read.dirs
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
places: &'a Places,
|
||||
/// The files read so far (a file including itself is read once).
|
||||
files: HashSet<PathBuf>,
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn file(&mut self, path: &Path, depth: usize) {
|
||||
if depth > MAX_DEPTH || self.files.len() >= MAX_FILES || !self.files.insert(path.to_path_buf()) {
|
||||
return;
|
||||
}
|
||||
// A folder of configuration files: those named as fontconfig reads them (a digit first,
|
||||
// `.conf` last), in name order.
|
||||
if let Ok(list) = std::fs::read_dir(path) {
|
||||
let mut files: Vec<PathBuf> = list
|
||||
.flatten()
|
||||
.take(MAX_FILES)
|
||||
.filter(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with(|c: char| c.is_ascii_digit()) && name.ends_with(".conf")
|
||||
})
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
self.file(&f, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A file that is missing, too large or not text lists nothing.
|
||||
if !std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() <= MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
for e in entries(&text) {
|
||||
let Some(to) = self.places.resolve(&e, path) else { continue };
|
||||
if e.include {
|
||||
self.file(&to, depth + 1);
|
||||
} else if self.dirs.len() < MAX_DIRS && !self.dirs.contains(&to) {
|
||||
self.dirs.push(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration file fontconfig starts from: `FONTCONFIG_FILE`, else `fonts.conf` in
|
||||
/// `FONTCONFIG_PATH`, else `/etc/fonts/fonts.conf`.
|
||||
pub(crate) fn root() -> PathBuf {
|
||||
let var = |name| std::env::var_os(name).map(PathBuf::from).filter(|p| p.is_absolute());
|
||||
var("FONTCONFIG_FILE").or_else(|| var("FONTCONFIG_PATH").map(|d| d.join("fonts.conf"))).unwrap_or_else(|| "/etc/fonts/fonts.conf".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(include: bool, prefix: &str, path: &str) -> Entry {
|
||||
Entry { include, prefix: prefix.into(), path: path.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirs_and_includes_are_read_and_the_rest_is_skipped() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<description>Default configuration file</description>
|
||||
<!-- <dir>/commented/out</dir> -->
|
||||
<dir>/usr/share/fonts</dir>
|
||||
<dir prefix="xdg">fonts</dir>
|
||||
<dir prefix = 'relative' salt="x"> more & more </dir>
|
||||
<dir>~/.fonts</dir>
|
||||
<dir></dir>
|
||||
<cachedir>/var/cache/fontconfig</cachedir>
|
||||
<match target="pattern"><test qual="any" name="family"><string>mono</string></test></match>
|
||||
<include ignore_missing="yes">conf.d</include>
|
||||
<include ignore_missing="yes" prefix="xdg">fontconfig/fonts.conf</include>
|
||||
<dir>/cut/off"#;
|
||||
assert_eq!(
|
||||
entries(xml),
|
||||
[
|
||||
entry(false, "", "/usr/share/fonts"),
|
||||
entry(false, "xdg", "fonts"),
|
||||
entry(false, "relative", "more & more"),
|
||||
entry(false, "", "~/.fonts"),
|
||||
entry(true, "", "conf.d"),
|
||||
entry(true, "xdg", "fontconfig/fonts.conf"),
|
||||
]
|
||||
);
|
||||
// Damaged files and files that aren't XML read as nothing, without a panic.
|
||||
for junk in
|
||||
["", "<", "<dir", "<dir>", "<dir><dir></dir>", "</dir>", "<!--", "<dir prefix=>x</dir", "\u{0}<dir prefix=\"", "<dir prefix='é>ü</dir>"]
|
||||
{
|
||||
let _ = entries(junk);
|
||||
}
|
||||
assert_eq!(attribute("dir notprefix=\"a\" prefix=\"b\"", "prefix"), Some("b"));
|
||||
}
|
||||
|
||||
/// A configuration as distributions lay it out: the main file lists the usual folders and
|
||||
/// includes `conf.d`, where a package's file adds its own folder (TeX fonts, #1529) and
|
||||
/// another includes the user's files.
|
||||
#[test]
|
||||
fn folders_of_included_files_are_listed_once_in_order() {
|
||||
let tmp = std::env::temp_dir().join(format!("vc-fontconfig-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let (etc, home) = (tmp.join("etc/fonts"), tmp.join("home"));
|
||||
let write = |path: PathBuf, body: &str| {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, format!("<fontconfig>{body}</fontconfig>")).unwrap();
|
||||
};
|
||||
let texmf = tmp.join("usr/share/texmf/fonts/opentype");
|
||||
write(
|
||||
etc.join("fonts.conf"),
|
||||
&format!(
|
||||
"<dir>{}</dir><dir prefix=\"xdg\">fonts</dir><dir>~/.fonts</dir><dir>fonts-of-the-working-folder</dir>\
|
||||
<include ignore_missing=\"yes\">conf.d</include><include>{}</include><include>fonts.conf</include>",
|
||||
tmp.join("usr/share/fonts").display(),
|
||||
etc.join("missing.conf").display()
|
||||
),
|
||||
);
|
||||
write(etc.join("conf.d/09-texlive.conf"), &format!("<dir>{}</dir>", texmf.display()));
|
||||
write(etc.join("conf.d/50-user.conf"), "<include ignore_missing=\"yes\" prefix=\"xdg\">fontconfig/fonts.conf</include>");
|
||||
write(etc.join("conf.d/README"), "<dir>/not/a/conf/file</dir>");
|
||||
write(etc.join("conf.d/user.conf"), "<dir>/no/digit/first</dir>");
|
||||
write(home.join(".config/fontconfig/fonts.conf"), &format!("<dir prefix=\"relative\">mine</dir><dir>{}</dir>", texmf.display()));
|
||||
let places = Places { home: Some(home.clone()), data_home: Some(home.join(".local/share")), config_home: Some(home.join(".config")) };
|
||||
assert_eq!(
|
||||
font_dirs(&etc.join("fonts.conf"), &places),
|
||||
[tmp.join("usr/share/fonts"), home.join(".local/share/fonts"), home.join(".fonts"), texmf, home.join(".config/fontconfig/mine")]
|
||||
);
|
||||
// Without a home folder, what needs one is left out; a missing file lists nothing.
|
||||
assert_eq!(font_dirs(&etc.join("fonts.conf"), &Places::default()).len(), 2);
|
||||
assert!(font_dirs(&tmp.join("none.conf"), &places).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
@@ -1269,12 +1269,21 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
// list their own data folders), relative paths ignored as the XDG spec says.
|
||||
let xdg = |var| std::env::var_os(var).map(|v| std::env::split_paths(&v).filter(|p| p.is_absolute()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let data_home = xdg("XDG_DATA_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".local/share")));
|
||||
dirs.extend(data_home.into_iter().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
|
||||
dirs.extend(data_home.iter().cloned().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
// In a Flatpak sandbox, the host's fonts (system, local and the user's).
|
||||
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
|
||||
// The folders fontconfig is set up with outside those: packages add their own (TeX fonts
|
||||
// under `/usr/share/texmf/fonts`, #1529), and `fc-list` and other apps show their fonts.
|
||||
let config_home = xdg("XDG_CONFIG_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".config")));
|
||||
let places = crate::fontconfig::Places { home: home.clone(), data_home, config_home };
|
||||
for dir in crate::fontconfig::font_dirs(&crate::fontconfig::root(), &places) {
|
||||
if !dirs.iter().any(|known| dir.starts_with(known)) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs.extend(app_font_dir().map(Path::to_path_buf));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod craft_fonts;
|
||||
pub mod edit;
|
||||
pub mod embed;
|
||||
mod features;
|
||||
mod fontconfig;
|
||||
mod fontdb;
|
||||
pub mod hyphen;
|
||||
mod layout;
|
||||
|
||||
+66
-29
@@ -161,7 +161,7 @@ impl Default for Bevel {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Extrude {
|
||||
pub depth: f64,
|
||||
/// The turn of the back face against the front, about the shape's centre.
|
||||
/// The turn of the back face against the front, about the shape's centre: 0° to 360°.
|
||||
pub twist: f64,
|
||||
/// The size of the back face, as a share (0.01..1) of the front's.
|
||||
pub taper: f64,
|
||||
@@ -185,11 +185,17 @@ pub struct Inflate {
|
||||
pub volume: f64,
|
||||
/// Puff the back out too; otherwise it stays flat.
|
||||
pub both_sides: bool,
|
||||
/// The turn of the back against the front, about the shape's centre: 0° to 360°.
|
||||
pub twist: f64,
|
||||
/// The size of the back, as a share (0.01..1) of the front's.
|
||||
pub taper: f64,
|
||||
/// A closed back (a solid) or none, so the inflated front is a shell open behind.
|
||||
pub cap: bool,
|
||||
}
|
||||
|
||||
impl Default for Inflate {
|
||||
fn default() -> Self {
|
||||
Self { depth: 0.0, volume: 50.0, both_sides: false }
|
||||
Self { depth: 0.0, volume: 50.0, both_sides: false, twist: 0.0, taper: 1.0, cap: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +239,7 @@ impl Extrude {
|
||||
fn sanitized(self) -> Self {
|
||||
Self {
|
||||
depth: finite(self.depth, 50.0, 0.0, 2000.0),
|
||||
twist: finite(self.twist, 0.0, -360.0, 360.0),
|
||||
twist: finite(self.twist, 0.0, 0.0, 360.0),
|
||||
taper: finite(self.taper, 1.0, 0.01, 1.0),
|
||||
bevel: self.bevel.map(|b| Bevel {
|
||||
width: finite(b.width, 4.0, 0.0, 1000.0),
|
||||
@@ -249,7 +255,13 @@ impl Extrude {
|
||||
|
||||
impl Inflate {
|
||||
fn sanitized(self) -> Self {
|
||||
Self { depth: finite(self.depth, 0.0, 0.0, 2000.0), volume: finite(self.volume, 50.0, 0.0, 100.0), ..self }
|
||||
Self {
|
||||
depth: finite(self.depth, 0.0, 0.0, 2000.0),
|
||||
volume: finite(self.volume, 50.0, 0.0, 100.0),
|
||||
twist: finite(self.twist, 0.0, 0.0, 360.0),
|
||||
taper: finite(self.taper, 1.0, 0.01, 1.0),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,10 +476,9 @@ impl Builder<'_> {
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
let quad = vec![a[i], a[j], b[j], b[i]];
|
||||
let mut normal = quad[1].sub(quad[0]).cross(quad[2].sub(quad[0]));
|
||||
if normal.dot(normal) < 1e-18 {
|
||||
normal = quad[2].sub(quad[0]).cross(quad[3].sub(quad[0]));
|
||||
}
|
||||
// A twist bends the strip between two slices into a saddle: its normal is the whole
|
||||
// quad's average (Newell's), not one corner's triangle, which leans far to one side.
|
||||
let mut normal = newell(std::slice::from_ref(&quad));
|
||||
if normal.dot(normal) < 1e-18 {
|
||||
continue;
|
||||
}
|
||||
@@ -517,15 +528,29 @@ fn outward(ring: &[Point], i: usize, z: f64) -> V3 {
|
||||
/// The cross-section of a side: each ring of the shape at depth fraction `t` (0 front, 1 back),
|
||||
/// turned by the twist and scaled by the taper about the centre of `bounds`.
|
||||
fn section(ring: &[Point], bounds: Rect, o: &Extrude, t: f64) -> Vec<Point> {
|
||||
let c = bounds.center();
|
||||
let (s, cos) = (o.twist * t).to_radians().sin_cos();
|
||||
let k = 1.0 + (o.taper - 1.0) * t;
|
||||
ring.iter()
|
||||
.map(|p| {
|
||||
let (x, y) = ((p.x - c.x) * k, (p.y - c.y) * k);
|
||||
Point::new(c.x + x * cos - y * s, c.y + x * s + y * cos)
|
||||
})
|
||||
.collect()
|
||||
let turn = Turn::new(bounds, o.twist, o.taper, t);
|
||||
ring.iter().map(|p| turn.apply(*p)).collect()
|
||||
}
|
||||
|
||||
/// The twist and taper at depth fraction `t` (0 front, 1 back): a turn by `twist · t` and a
|
||||
/// scale from 1 to `taper`, about the centre of the bounds.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Turn {
|
||||
centre: Point,
|
||||
sin: f64,
|
||||
cos: f64,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
fn new(bounds: Rect, twist: f64, taper: f64, t: f64) -> Self {
|
||||
let (sin, cos) = (twist * t).to_radians().sin_cos();
|
||||
Self { centre: bounds.center(), sin, cos, scale: 1.0 + (taper - 1.0) * t }
|
||||
}
|
||||
fn apply(&self, p: Point) -> Point {
|
||||
let (x, y) = ((p.x - self.centre.x) * self.scale, (p.y - self.centre.y) * self.scale);
|
||||
Point::new(self.centre.x + x * self.cos - y * self.sin, self.centre.y + x * self.sin + y * self.cos)
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of an extrusion's outline: how far it is inset (negative: outset) and its depth
|
||||
@@ -997,8 +1022,11 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
|
||||
height * (m.at(p).max(0.0) / m.peak).sqrt()
|
||||
};
|
||||
let camera = Camera::new(view, bounds, o.depth + 2.0 * height);
|
||||
let mut b = Builder { camera: &camera, closed: true, facets: vec![] };
|
||||
// Without its back cap the object is a shell open behind, its inside showing.
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
|
||||
let half = o.depth / 2.0;
|
||||
// The back: turned and scaled by the twist and taper, its puff too.
|
||||
let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0);
|
||||
// The grid's cells, those the surface rises steeply across (near the outline) cut into
|
||||
// halves or quarters each way, so the steep rim is drawn in facets as fine as its curve.
|
||||
// Every corner sits on one fine lattice (a quarter cell), computed the same way wherever it
|
||||
@@ -1119,16 +1147,18 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
|
||||
// Each piece lifted onto the surface. Its normal is the lifted piece's own average
|
||||
// (Newell's), not the slope at one point: near the edge the surface turns
|
||||
// vertical, and a point sample there misjudges which way a whole piece faces.
|
||||
let lifted = |sign: f64| -> Vec<Vec<V3>> {
|
||||
pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, sign * (half + h))).collect()).collect()
|
||||
};
|
||||
let front = lifted(1.0);
|
||||
let front: Vec<Vec<V3>> =
|
||||
pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, half + h)).collect()).collect();
|
||||
let n = newell(&front);
|
||||
b.face(front, n)?;
|
||||
if o.both_sides {
|
||||
if o.both_sides && o.cap {
|
||||
// The back winds the same way round as the front, so its outward normal is
|
||||
// reversed.
|
||||
let back = lifted(-1.0);
|
||||
let back: Vec<Vec<V3>> = pieces
|
||||
.iter()
|
||||
.zip(&lifts)
|
||||
.map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(back_turn.apply(*q), -half - h * back_turn.scale)).collect())
|
||||
.collect();
|
||||
let n = newell(&back);
|
||||
b.face(back, V3(-n.0, -n.1, -n.2))?;
|
||||
}
|
||||
@@ -1136,15 +1166,22 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
|
||||
}
|
||||
}
|
||||
}
|
||||
if !o.both_sides {
|
||||
let back: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, -half)).collect()).collect();
|
||||
if !o.both_sides && o.cap {
|
||||
let back: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(back_turn.apply(*p), -half)).collect()).collect();
|
||||
b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)))?;
|
||||
}
|
||||
if o.depth > 0.0 {
|
||||
// The sides, cut into slices that follow a twist or taper.
|
||||
let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 };
|
||||
for ring in &rings {
|
||||
let front: Vec<V3> = ring.iter().map(|p| camera.place(*p, half)).collect();
|
||||
let back: Vec<V3> = ring.iter().map(|p| camera.place(*p, -half)).collect();
|
||||
b.band(&front, &back, |i| outward(ring, i, 0.0))?;
|
||||
let at = |k: usize| -> Vec<V3> {
|
||||
let t = k as f64 / slices as f64;
|
||||
let turn = Turn::new(bounds, o.twist, o.taper, t);
|
||||
ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect()
|
||||
};
|
||||
for k in 0..slices {
|
||||
b.band(&at(k), &at(k + 1), |i| outward(ring, i, 0.0))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(b.finish())
|
||||
|
||||
@@ -218,7 +218,10 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||
};
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 9.0, ..front() }).unwrap();
|
||||
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert!(inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true }, front()).is_ok());
|
||||
assert!(
|
||||
inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front())
|
||||
.is_ok()
|
||||
);
|
||||
assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// A line has no area to extrude.
|
||||
let mut line = BezPath::new();
|
||||
@@ -362,3 +365,113 @@ fn the_edge_index_looks_along_the_side_from_outside() {
|
||||
let (fast, slow) = (edges.distance(q), edge_distance(&rings, q));
|
||||
assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}");
|
||||
}
|
||||
|
||||
/// Twist runs 0° to 360°, as documented: beyond either end it holds at that end.
|
||||
#[test]
|
||||
fn twist_holds_between_0_and_360_degrees() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||
let at = |twist: f64| extrude(&p, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
|
||||
assert_eq!(at(-45.0), at(0.0));
|
||||
assert_eq!(at(500.0), at(360.0));
|
||||
assert_ne!(at(45.0), at(0.0));
|
||||
}
|
||||
|
||||
/// A twisted extrusion shows every wall facing the viewer: a twist bends each strip between two
|
||||
/// slices into a saddle, and its normal from one corner's triangle leaned so far back that from
|
||||
/// 60° on every wall was dropped. Checked against each strip's average normal, oriented away from
|
||||
/// the square's axis.
|
||||
#[test]
|
||||
fn twisted_walls_face_the_way_they_turn() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let c = b.center();
|
||||
for twist in [60.0, 120.0, 180.0, 300.0] {
|
||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] {
|
||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||
let o = Extrude { twist, ..Extrude::default() };
|
||||
let got = extrude(&p, b, o, v).unwrap();
|
||||
// Expected: walls (and caps) whose outward normal faces the viewer.
|
||||
let o = o.sanitized();
|
||||
let camera = Camera::new(v, b, o.depth);
|
||||
let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
|
||||
let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64);
|
||||
let z = |t: f64| o.depth / 2.0 - o.depth * t;
|
||||
let mut expected = 0;
|
||||
for k in 0..slices {
|
||||
let (t0, t1) = (k as f64 / slices as f64, (k + 1) as f64 / slices as f64);
|
||||
let (r0, r1) = (section(ring, b, &o, t0), section(ring, b, &o, t1));
|
||||
for i in 0..ring.len() {
|
||||
let j = (i + 1) % ring.len();
|
||||
let q = [camera.place(r0[i], z(t0)), camera.place(r0[j], z(t0)), camera.place(r1[j], z(t1)), camera.place(r1[i], z(t1))];
|
||||
let mut n = newell(&[q.to_vec()]).unit();
|
||||
let mid = r0[i].midpoint(r1[j]);
|
||||
let away = camera.rotate(V3(mid.x - c.x, mid.y - c.y, 0.0));
|
||||
if n.dot(away) < 0.0 {
|
||||
n = V3(-n.0, -n.1, -n.2);
|
||||
}
|
||||
let centre = V3((q[0].0 + q[2].0) / 2.0, (q[0].1 + q[2].1) / 2.0, (q[0].2 + q[2].2) / 2.0);
|
||||
// Kept as the builder keeps them: facing, or seen almost edge-on, with an area.
|
||||
let projected: Vec<Point> = q.iter().map(|v| camera.project(*v)).collect();
|
||||
if camera.facing(centre, n) > -0.02 && signed_area(&projected).abs() >= 1e-10 {
|
||||
expected += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (n, t) in [(1.0, 0.0), (-1.0, 1.0)] {
|
||||
let cap: Vec<Point> = section(ring, b, &o, t).iter().map(|p| camera.project(camera.place(*p, z(t)))).collect();
|
||||
if camera.facing(V3::default(), camera.rotate(V3(0.0, 0.0, n))) > -0.02 && signed_area(&cap).abs() >= 1e-10 {
|
||||
expected += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(got.len(), expected, "twist {twist}, view ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inflate takes Twist, Taper and Cap like the other types: the back turned and scaled (its puff
|
||||
/// too), the sides following, and without a cap no back at all.
|
||||
#[test]
|
||||
fn inflate_twists_tapers_and_opens_its_back() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() };
|
||||
// From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50
|
||||
// at a 50 % taper.
|
||||
let tapered = inflate(&p, b, Inflate { taper: 0.5, ..o }, side).unwrap();
|
||||
let e = extent(&tapered);
|
||||
assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}");
|
||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect();
|
||||
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||
assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo);
|
||||
// From behind, a 45° twist shows the flat back as a diamond.
|
||||
let behind = View { rotation_y: 180.0, ..front() };
|
||||
let twisted = inflate(&p, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
|
||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||
// Hollow: no back; from behind the inside of the inflated front shows instead of a flat face.
|
||||
let solid = inflate(&p, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
|
||||
assert_eq!(solid.len(), 1, "the flat back alone");
|
||||
let hollow = inflate(&p, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
|
||||
assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12));
|
||||
// With both sides puffed, the back puff is scaled with the taper: lower and smaller.
|
||||
let both = |taper: f64| extent(&inflate(&p, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
|
||||
assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6);
|
||||
// Out of range values hold at the documented ends.
|
||||
let at = |i: Inflate| inflate(&p, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o));
|
||||
assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o }));
|
||||
}
|
||||
|
||||
/// At 0 % volume an inflated shape is the extruded one: twisted and tapered, their outlines
|
||||
/// agree from every side (no wall goes missing in either).
|
||||
#[test]
|
||||
fn a_flat_twisted_inflate_matches_the_extrusion() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] {
|
||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||
let e = extent(&extrude(&p, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
|
||||
let i = extent(&inflate(&p, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap());
|
||||
for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] {
|
||||
assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,10 +235,13 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The anchor within `tol` of `p` nearest to it, of the topmost visible path or area type frame
|
||||
/// that has one: (id, si, ai, where it is).
|
||||
/// The anchor within `tol` of `p` nearest to it, of the topmost selected path or area type frame
|
||||
/// that has one, else of the topmost visible one: (id, si, ai, where it is). Selected objects come
|
||||
/// first so an object above can't take the press from the one being edited (#1508), as
|
||||
/// [`hovered_anchor`] marks it.
|
||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
|
||||
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||||
let mut owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||||
owners.sort_by_key(|id| !cx.selection.contains(*id));
|
||||
owners.into_iter().find_map(|id| {
|
||||
let pd = cx.doc.node(id).and_then(editable_path)?;
|
||||
nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a))
|
||||
@@ -1224,6 +1227,32 @@ mod tests {
|
||||
(d, a, b)
|
||||
}
|
||||
|
||||
/// An anchor of a selected path is picked before the one of an unselected path on a layer
|
||||
/// above that covers it, as Highlight anchors on mouse over already marks it (#1508); with
|
||||
/// nothing selected, the topmost path's anchor is picked.
|
||||
#[test]
|
||||
fn a_selected_path_s_anchor_is_picked_before_one_above_it() {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
let top = d.add_layer(None);
|
||||
let b = d.alloc_id();
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(101.0, 100.0, 201.0, 200.0));
|
||||
d.insert(Some(top), 0, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let p = paint();
|
||||
let pick = |id: NodeId| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))];
|
||||
let s = anchors_of(a, &[(0, 2)]);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(a));
|
||||
let mut whole = Selection::default();
|
||||
whole.set([a]);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let cx2 = cx(&d, &whole, &p);
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 101.0, 100.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 101.0, 100.0)), pick(a));
|
||||
let none = Selection::default();
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert_eq!(t.pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(b));
|
||||
}
|
||||
|
||||
/// The anchors `anchors` of path `id` direct-selected.
|
||||
fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
|
||||
@@ -51,7 +51,8 @@ fn step(d: u8) -> (i32, i32) {
|
||||
fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
|
||||
let mut comp = vec![u32::MAX; w * h];
|
||||
let mut sizes = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
// (Indices as `u32`: a mask has at most 64 million pixels, and this stack can hold them all.)
|
||||
let mut stack: Vec<u32> = Vec::new();
|
||||
for s in 0..w * h {
|
||||
if !mask[s] || comp[s] != u32::MAX {
|
||||
continue;
|
||||
@@ -59,14 +60,15 @@ fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
|
||||
let id = sizes.len() as u32;
|
||||
let mut n = 0usize;
|
||||
comp[s] = id;
|
||||
stack.push(s);
|
||||
stack.push(s as u32);
|
||||
while let Some(i) = stack.pop() {
|
||||
let i = i as usize;
|
||||
n += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut go = |j: usize| {
|
||||
if mask[j] && comp[j] == u32::MAX {
|
||||
comp[j] = id;
|
||||
stack.push(j);
|
||||
stack.push(j as u32);
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
|
||||
+86
-15
@@ -4,7 +4,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, CubicBez, ParamCurve, PathData, Point
|
||||
use vectorcraft_pathops::{SimplifyOptions, simplify_with};
|
||||
|
||||
use crate::contour::Loop;
|
||||
use crate::logo::{Cubic, Stats, Style, all_within, fit_outline, resample_closed, sample_cubics};
|
||||
use crate::logo::{Cubic, Stats, Style, all_within, fit_open, fit_outline, resample_closed, resample_open, sample_cubics};
|
||||
use crate::subpixel::{self, Crack};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -48,27 +48,98 @@ fn rdp(pts: &[Point], a: usize, b: usize, tol: f64, keep: &mut [bool]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit one loop of a `w × h` image into a closed Bézier subpath (pixel coordinates): its points
|
||||
/// are placed to a fraction of a pixel ([`subpixel::outline`], which explains `shift`) and fitted as
|
||||
/// Flat Logo mode fits its outlines (corners restored, straight runs as lines, the rest as few
|
||||
/// curves as `style.tol` allows).
|
||||
pub(crate) fn fit_loop(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64, style: &Style) -> Option<SubPath> {
|
||||
let (pts, hard) = subpixel::outline(l, w, h, shift);
|
||||
let mut style = *style;
|
||||
if hard {
|
||||
// No anti-aliasing: the edge is only known to about half a pixel (as in Flat Logo mode).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 1.8;
|
||||
style.circle_tol *= 1.8;
|
||||
style.sigma = 0.9;
|
||||
fn reversed(mut segs: Vec<Cubic>) -> Vec<Cubic> {
|
||||
segs.reverse();
|
||||
for c in &mut segs {
|
||||
c.reverse();
|
||||
}
|
||||
segs
|
||||
}
|
||||
|
||||
fn before(a: Point, b: Point) -> std::cmp::Ordering {
|
||||
a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
|
||||
}
|
||||
|
||||
/// A whole loop fitted. The same loop gives the same curves whichever way it runs and wherever it
|
||||
/// starts (it is fitted clockwise from its leftmost point): the outline of a shape and the hole it
|
||||
/// leaves in the shape around it are one curve.
|
||||
fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
|
||||
let n = pts.len();
|
||||
let area2: f64 = (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum();
|
||||
let flip = area2 < 0.0;
|
||||
let mut pts = pts.to_vec();
|
||||
if flip {
|
||||
pts.reverse();
|
||||
}
|
||||
let start = (0..n).min_by(|&i, &j| before(pts[i], pts[j])).unwrap_or(0);
|
||||
pts.rotate_left(start);
|
||||
let mut stats = Stats::default();
|
||||
let mut segs = fit_outline(&pts, &style, &mut stats)?;
|
||||
let mut segs = fit_outline(&pts, style, &mut stats)?;
|
||||
// A fit that strayed was replaced by a plain one that nothing has checked: one that strays too
|
||||
// (a wild handle on a tiny or ragged loop) gives way to the outline's own points.
|
||||
if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
|
||||
segs = polygon(&pts, style.tol);
|
||||
}
|
||||
Some(if flip { reversed(segs) } else { segs })
|
||||
}
|
||||
|
||||
/// A piece of a loop between two junctions fitted, from its first point to its last. The same
|
||||
/// piece gives the same curve whichever way it runs (it is fitted from its end further left): the
|
||||
/// edge two regions share is one curve.
|
||||
fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
|
||||
let n = pts.len();
|
||||
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return vec![] };
|
||||
let flip = match before(first, last) {
|
||||
std::cmp::Ordering::Equal => n > 3 && before(pts[1], pts[n - 2]).is_gt(),
|
||||
order => order.is_gt(),
|
||||
};
|
||||
let mut pts = pts.to_vec();
|
||||
if flip {
|
||||
pts.reverse();
|
||||
}
|
||||
let mut segs = fit_open(&pts, style, &mut Stats::default());
|
||||
// Nothing else checks this fit: one that strays gives way to the piece's own points.
|
||||
if !all_within(&sample_cubics(&segs, 0.25), &resample_open(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
|
||||
let mut keep = vec![false; n];
|
||||
rdp(&pts, 0, n - 1, style.tol, &mut keep);
|
||||
keep[0] = true;
|
||||
keep[n - 1] = true;
|
||||
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
segs = kept.windows(2).filter(|p| p[0] != p[1]).map(|p| [p[0], p[0], p[1], p[1]]).collect();
|
||||
}
|
||||
if flip { reversed(segs) } else { segs }
|
||||
}
|
||||
|
||||
/// Fit one loop of a `w × h` image into a closed Bézier subpath (pixel coordinates): its points
|
||||
/// are placed to a fraction of a pixel ([`subpixel::outline`], which explains `across`) and fitted
|
||||
/// as Flat Logo mode fits its outlines (corners restored, straight runs as lines, the rest as few
|
||||
/// curves as `style.tol` allows), piece by piece between its junctions, so that the regions on
|
||||
/// either side of an edge get the same curve for it.
|
||||
pub(crate) fn fit_loop(
|
||||
l: &Loop,
|
||||
w: usize,
|
||||
h: usize,
|
||||
across: &dyn Fn(Crack) -> (f64, u32),
|
||||
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
|
||||
style: &Style,
|
||||
) -> Option<SubPath> {
|
||||
let pieces = subpixel::outline(l, w, h, across, junction_at);
|
||||
let mut segs: Vec<Cubic> = Vec::new();
|
||||
for piece in &pieces {
|
||||
let mut style = *style;
|
||||
if piece.hard {
|
||||
// No anti-aliasing: the edge is only known to about half a pixel (as in Flat Logo mode).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 1.8;
|
||||
style.circle_tol *= 1.8;
|
||||
style.sigma = 0.9;
|
||||
}
|
||||
if piece.closed {
|
||||
segs = fit_closed(&piece.pts, &style)?;
|
||||
} else {
|
||||
segs.extend(fit_piece(&piece.pts, &style));
|
||||
}
|
||||
}
|
||||
let n = segs.len();
|
||||
// A straight piece has no handles.
|
||||
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
|
||||
|
||||
+163
-34
@@ -25,6 +25,9 @@
|
||||
//! is (an anti-aliased or blurred edge pixel is part one tone, part the other), so the outline
|
||||
//! is right to a fraction of a pixel before any fitting; hard-edged images are read as
|
||||
//! staircases instead (see `subpixel`).
|
||||
//! Outlines are cut at their junctions (where three regions meet, and at the image's border)
|
||||
//! and each piece between two is fitted on its own, the same way from either side, so that two
|
||||
//! abutting regions have one and the same curve along the edge they share.
|
||||
//! 5. **Curves**: the outline is fitted as Flat Logo mode fits its own (`logo::fit_outline`):
|
||||
//! corners the pixels rounded are put back, straight runs become lines, a round outline a
|
||||
//! true circle, and the rest as few cubic Béziers as the Paths tolerance allows; Snap Curves To
|
||||
@@ -266,8 +269,12 @@ pub struct TraceParams {
|
||||
/// `noise / 10` pixels of the source image.)
|
||||
pub noise: u32,
|
||||
pub method: Method,
|
||||
/// Drop white areas (no white background shape).
|
||||
/// Ignore Color: drop the areas of one colour (no background shape): white, unless
|
||||
/// `ignore_color` names another.
|
||||
pub ignore_white: bool,
|
||||
/// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels
|
||||
/// of it. `None` is white. (Flat Logo mode leaves out white only.)
|
||||
pub ignore_color: Option<String>,
|
||||
/// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs
|
||||
/// to true circles.
|
||||
pub snap_curves_to_lines: bool,
|
||||
@@ -299,6 +306,7 @@ impl Default for TraceParams {
|
||||
noise: 25,
|
||||
method: Method::Abutting,
|
||||
ignore_white: false,
|
||||
ignore_color: None,
|
||||
snap_curves_to_lines: false,
|
||||
fills: true,
|
||||
strokes: false,
|
||||
@@ -350,6 +358,7 @@ impl TraceParams {
|
||||
/// Built-in preset names in panel order (our own parameter sets).
|
||||
pub const PRESET_NAMES: &[&str] = &[
|
||||
"Default",
|
||||
"Auto Color",
|
||||
"High Fidelity Photo",
|
||||
"Low Fidelity Photo",
|
||||
"3 Colors",
|
||||
@@ -361,6 +370,7 @@ pub const PRESET_NAMES: &[&str] = &[
|
||||
"Silhouettes",
|
||||
"Line Art",
|
||||
"Technical Drawing",
|
||||
"Outline",
|
||||
"Flat Logo",
|
||||
];
|
||||
|
||||
@@ -389,6 +399,10 @@ pub fn preset(name: &str) -> Option<TraceParams> {
|
||||
};
|
||||
Some(match key.as_str() {
|
||||
"default" => d,
|
||||
// A posterised image of a photo or of artwork: its own colours where it is flat art, as
|
||||
// many as its tones need where it is not.
|
||||
// (Color detail 85: a colour on a hundredth of the picture is still one of its colours.)
|
||||
"auto color" => TraceParams { palette: Palette::Automatic, color_detail: 85.0, ..color(6, 65.0, 60.0, 16, Method::Abutting) },
|
||||
"high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping),
|
||||
"low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping),
|
||||
"3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting),
|
||||
@@ -400,6 +414,8 @@ pub fn preset(name: &str) -> Option<TraceParams> {
|
||||
"silhouettes" => bw(200, 40.0, 60.0, 30, false),
|
||||
"line art" => bw(128, 80.0, 70.0, 10, false),
|
||||
"technical drawing" => bw(128, 95.0, 90.0, 4, true),
|
||||
// Black outlines: lines as stroked centre lines, wider areas as stroked outlines.
|
||||
"outline" => TraceParams { fills: false, strokes: true, ..bw(128, 80.0, 70.0, 10, false) },
|
||||
// Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel.
|
||||
"flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d },
|
||||
_ => return None,
|
||||
@@ -443,6 +459,14 @@ fn is_white(c: [u8; 3]) -> bool {
|
||||
c.iter().all(|&v| v >= 245)
|
||||
}
|
||||
|
||||
/// Whether Ignore Color leaves palette colour `c` out: `target` is the colour picked (`None`: white).
|
||||
fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool {
|
||||
match target {
|
||||
None => is_white(c),
|
||||
Some(t) => (0..3).map(|k| (f64::from(c[k]) - f64::from(t[k])).powi(2)).sum::<f64>() <= 30.0 * 30.0 || (is_white(t) && is_white(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use
|
||||
/// [`trace_within`] to see why.
|
||||
pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
|
||||
@@ -461,16 +485,24 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
if params.mode == Mode::Logo {
|
||||
return logo::trace_logo(img, params, max_anchors);
|
||||
}
|
||||
// The colour Ignore Color leaves out, if not white.
|
||||
let ignore = match ¶ms.ignore_color {
|
||||
Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?),
|
||||
_ => None,
|
||||
};
|
||||
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
|
||||
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
|
||||
let img = smoothed.as_ref().unwrap_or(img);
|
||||
let mut q = quantize(img, params);
|
||||
denoise(&mut q.labels, w, h, params.noise as usize);
|
||||
// Pixel count per palette entry.
|
||||
// Pixel count per palette entry, and the box its pixels lie in.
|
||||
let mut counts = vec![0usize; q.palette.len()];
|
||||
for &l in &q.labels {
|
||||
if let Some(c) = counts.get_mut(l as usize) {
|
||||
let mut boxes: Vec<Option<(usize, usize, usize, usize)>> = vec![None; q.palette.len()];
|
||||
for (i, &l) in q.labels.iter().enumerate() {
|
||||
if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) {
|
||||
*c += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
*b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1))));
|
||||
}
|
||||
}
|
||||
// Layers bottom → top: largest area first.
|
||||
@@ -494,12 +526,14 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
rank[i] = r;
|
||||
}
|
||||
let layers: Vec<(usize, usize)> =
|
||||
order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_white(q.palette[ci]))).collect();
|
||||
order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect();
|
||||
let job = Layers {
|
||||
q: &q,
|
||||
rank: &rank,
|
||||
params,
|
||||
img,
|
||||
ignore,
|
||||
boxes,
|
||||
bw_tones: bw_tones(img, &q),
|
||||
style: params.outline_style(),
|
||||
opts: fit::FitOptions {
|
||||
@@ -514,7 +548,12 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
max_anchors,
|
||||
};
|
||||
let mut out = TraceResult::default();
|
||||
for (paths, &(_, ci)) in parallel_map(&layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
|
||||
// A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels
|
||||
// of its components, the stack that fills them, its edges): as many layers at a time as fit
|
||||
// in half a gigabyte, so that a large image is not traced at the cost of gigabytes.
|
||||
let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1);
|
||||
let at_a_time = ((512 << 20) / (largest * 10)).max(1);
|
||||
for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
|
||||
out.paths.extend(paths.ok_or(TraceError::TooComplex { max: max_anchors })?);
|
||||
let color = q.palette[ci];
|
||||
if !out.palette.contains(&color) {
|
||||
@@ -524,17 +563,32 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The mean luminance of the pixels labelled 0 (white) and 1 (black) of a Black and White
|
||||
/// quantisation: pure white and black where a side has no pixel.
|
||||
/// The luminance of white and of black in a Black and White quantisation: what its lightest
|
||||
/// white pixels and its darkest black ones have (the 98th and 2nd percentiles; pure white and
|
||||
/// black where a side has no pixel). Not the means: where the black is thin lines, blurred or
|
||||
/// scanned, most black pixels are part paper, their mean is a gray, and read against it the middle
|
||||
/// of every line would count as more than all ink, now more and now less as the line crosses the
|
||||
/// pixel rows, which put a ripple on its edges.
|
||||
fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] {
|
||||
let (mut sum, mut n) = ([0.0f64; 2], [0.0f64; 2]);
|
||||
let mut hist = [[0u64; 256]; 2];
|
||||
for (p, &l) in img.rgba.as_chunks::<4>().0.iter().zip(&q.labels) {
|
||||
if let (Some(s), Some(n)) = (sum.get_mut(l as usize), n.get_mut(l as usize)) {
|
||||
*s += (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0;
|
||||
*n += 1.0;
|
||||
if let Some(h) = hist.get_mut(l as usize) {
|
||||
h[((u32::from(p[0]) * 299 + u32::from(p[1]) * 587 + u32::from(p[2]) * 114 + 500) / 1000).min(255) as usize] += 1;
|
||||
}
|
||||
}
|
||||
[if n[0] > 0.0 { sum[0] / n[0] } else { 255.0 }, if n[1] > 0.0 { sum[1] / n[1] } else { 0.0 }]
|
||||
// The luminance a share `at` of the side's pixels are darker than.
|
||||
let percentile = |h: &[u64; 256], at: f64, none: f64| {
|
||||
let total: u64 = h.iter().sum();
|
||||
let mut seen = 0;
|
||||
for (v, n) in h.iter().enumerate() {
|
||||
seen += n;
|
||||
if total > 0 && seen as f64 >= at * total as f64 {
|
||||
return v as f64;
|
||||
}
|
||||
}
|
||||
none
|
||||
};
|
||||
[percentile(&hist[0], 0.98, 255.0), percentile(&hist[1], 0.02, 0.0)]
|
||||
}
|
||||
|
||||
/// What tracing one colour layer needs.
|
||||
@@ -544,7 +598,11 @@ struct Layers<'a> {
|
||||
rank: &'a [usize],
|
||||
params: &'a TraceParams,
|
||||
img: &'a Raster,
|
||||
/// Black and White: the mean luminance of the white pixels and of the black ones.
|
||||
/// The colour Ignore Color leaves out, if not white.
|
||||
ignore: Option<[u8; 3]>,
|
||||
/// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded.
|
||||
boxes: Vec<Option<(usize, usize, usize, usize)>>,
|
||||
/// Black and White: the luminance of white and of black (see `bw_tones`).
|
||||
bw_tones: [f64; 2],
|
||||
/// How outlines are fitted.
|
||||
style: logo::Style,
|
||||
@@ -561,12 +619,52 @@ impl Layers<'_> {
|
||||
/// Ignore White: white is no part of any layer (stacked layers reach under the layers above
|
||||
/// them, and must not reach under white that is left out).
|
||||
fn ignored(&self, label: u16) -> bool {
|
||||
self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_white(*c))
|
||||
self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore))
|
||||
}
|
||||
/// The region across a crack, for cutting outlines at their junctions: the outside pixel's
|
||||
/// colour. Stacked layers end on many colours at once and share no edge: one neighbour.
|
||||
fn neighbour(&self, c: Crack) -> u32 {
|
||||
if self.params.method == Method::Overlapping {
|
||||
return 0;
|
||||
}
|
||||
c.at(1).and_then(|i| self.q.labels.get(i)).map_or(u32::from(TRANSPARENT), |&l| u32::from(l))
|
||||
}
|
||||
/// The junction at pixel corner `(x, y)`, if there is one: where three or four regions
|
||||
/// meet, or two parts of a region touch by the corner only, or an edge reaches the image's
|
||||
/// border. It lies at the corner, moved across by what the edges that run up and down from it
|
||||
/// say, and up or down by what those to its left and right say (the mean, where there are
|
||||
/// two). It depends on the corner alone, so every region around it gets the same point; on the
|
||||
/// image's border it stays on the border.
|
||||
fn junction(&self, x: i32, y: i32) -> Option<vectorcraft_geom::Point> {
|
||||
let label = |px: i32, py: i32| Crack::new((px, py), (1, 0), self.w, self.h).at(0).and_then(|i| self.q.labels.get(i).copied());
|
||||
// The four pixels around the corner (`None` off the image), and the edges between them.
|
||||
let around = [label(x - 1, y - 1), label(x, y - 1), label(x, y), label(x - 1, y)];
|
||||
let edges = (0..4).filter(|&i| around[i] != around[(i + 1) % 4]).count();
|
||||
let apart = |a: usize, b: usize, c: usize, d: usize| around[a] == around[c] && around[b] != around[a] && around[d] != around[a];
|
||||
let touching = apart(0, 1, 2, 3) || apart(1, 2, 3, 0);
|
||||
// (Stacked layers share no edges: they are cut at the image's border only.)
|
||||
if edges < 3 && !touching || self.params.method == Method::Overlapping {
|
||||
return None;
|
||||
}
|
||||
// The shift of the crack between `a` and the pixel `step` from it, if both are in the
|
||||
// image and of different regions.
|
||||
let shift = |a: (i32, i32), step: (i32, i32)| {
|
||||
let (la, lb) = (label(a.0, a.1)?, label(a.0 + step.0, a.1 + step.1)?);
|
||||
(la != lb).then(|| self.shift(Crack::new(a, step, self.w, self.h)).clamp(-0.5, 0.5))
|
||||
};
|
||||
let mean = |a: Option<f64>, b: Option<f64>| match (a, b) {
|
||||
(Some(a), Some(b)) => (a + b) / 2.0,
|
||||
(Some(v), None) | (None, Some(v)) => v,
|
||||
(None, None) => 0.0,
|
||||
};
|
||||
let dx = mean(shift((x - 1, y - 1), (1, 0)), shift((x - 1, y), (1, 0)));
|
||||
let dy = mean(shift((x - 1, y - 1), (0, 1)), shift((x, y - 1), (0, 1)));
|
||||
Some(vectorcraft_geom::Point::new(f64::from(x) + dx, f64::from(y) + dy))
|
||||
}
|
||||
/// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5).
|
||||
///
|
||||
/// Each pixel of the column across the crack is read as a blend of the tones on its two sides
|
||||
/// (the two pixels' palette colours; in Black and White the mean luminance of the black and of
|
||||
/// (the two pixels' palette colours; in Black and White the luminance of the image's black and of
|
||||
/// the white pixels). The edge is where the blends, summed, put it: as far out as there is
|
||||
/// inside tone in the outside pixels, less the outside tone in the inside ones (two pixels each
|
||||
/// way, while they are on the same side and no more blended than the pixel before: more would be
|
||||
@@ -587,8 +685,20 @@ impl Layers<'_> {
|
||||
return 0.0;
|
||||
}
|
||||
let bw = self.params.mode == Mode::BlackAndWhite;
|
||||
// Black and White: black is the image's black; white is the lightest pixel within a few
|
||||
// pixels of the crack, the paper as it is here (a scan's paper is darker in one corner than
|
||||
// in another, and read against the image's lightest white it would count as part ink).
|
||||
let black = self.bw_tones[1];
|
||||
let white = if bw {
|
||||
let light = (-3..=4)
|
||||
.filter_map(|k| c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)))
|
||||
.map(|p| (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0);
|
||||
light.fold(f64::NEG_INFINITY, f64::max).clamp((black + 32.0).min(self.bw_tones[0]), self.bw_tones[0])
|
||||
} else {
|
||||
self.bw_tones[0]
|
||||
};
|
||||
let (ca, cb) = if bw {
|
||||
let tone = |l: u16| [self.bw_tones[usize::from(l == 1)]; 3];
|
||||
let tone = |l: u16| [if l == 1 { black } else { white }; 3];
|
||||
(tone(la), tone(lb))
|
||||
} else {
|
||||
let (Some(ca), Some(cb)) = (self.q.palette.get(la as usize), self.q.palette.get(lb as usize)) else { return 0.0 };
|
||||
@@ -646,7 +756,6 @@ impl Layers<'_> {
|
||||
};
|
||||
if bw {
|
||||
// The share of black at which a pixel turns black.
|
||||
let (white, black) = (self.bw_tones[0], self.bw_tones[1]);
|
||||
let turn = ((white - f64::from(self.params.threshold)) / (white - black).max(1.0)).clamp(0.02, 0.98);
|
||||
shift += if la == 1 { 1.0 } else { -1.0 } * (0.5 - turn) / steep;
|
||||
}
|
||||
@@ -666,17 +775,23 @@ impl Layers<'_> {
|
||||
} else {
|
||||
(vec![], vec![])
|
||||
};
|
||||
let mask: Vec<bool> = self
|
||||
.q
|
||||
.labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &l)| {
|
||||
l != TRANSPARENT
|
||||
&& !thin.get(i).copied().unwrap_or(false)
|
||||
&& if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) && !self.ignored(l) } else { l as usize == ci }
|
||||
})
|
||||
.collect();
|
||||
// The layer is traced within the box its pixels lie in, not the whole image.
|
||||
let part = |l: u16| if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) && !self.ignored(l) } else { l as usize == ci };
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (l, b) in self.boxes.iter().enumerate() {
|
||||
if let Some(b) = b.filter(|_| part(l as u16)) {
|
||||
(x0, y0, x1, y1) = (x0.min(b.0), y0.min(b.1), x1.max(b.2), y1.max(b.3));
|
||||
}
|
||||
}
|
||||
if x0 >= x1 || y0 >= y1 {
|
||||
return Some(vec![]);
|
||||
}
|
||||
let (bw, bh) = (x1 - x0, y1 - y0);
|
||||
let mut mask = Vec::with_capacity(bw * bh);
|
||||
for y in y0..y1 {
|
||||
let row = self.q.labels.get(y * w + x0..y * w + x1)?;
|
||||
mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l)));
|
||||
}
|
||||
// Without Fills, areas are outlined with a 1 px stroke.
|
||||
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
|
||||
let min_hole = params.noise.max(1) as i64;
|
||||
@@ -689,8 +804,17 @@ impl Layers<'_> {
|
||||
paths.push(TracedPath { path, color, pixels, stroke });
|
||||
true
|
||||
};
|
||||
for comp in trace_mask(&mask, w, h) {
|
||||
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| self.shift(c), &self.style);
|
||||
let mut comps = trace_mask(&mask, bw, bh);
|
||||
drop(mask);
|
||||
for comp in &mut comps {
|
||||
for l in std::iter::once(&mut comp.outer).chain(&mut comp.holes) {
|
||||
for p in &mut l.pts {
|
||||
*p = (p.0 + x0 as i32, p.1 + y0 as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for comp in comps {
|
||||
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style);
|
||||
let Some(outer) = fit(&comp.outer) else { continue };
|
||||
let mut subs = vec![outer];
|
||||
for hole in &comp.holes {
|
||||
@@ -713,9 +837,14 @@ impl Layers<'_> {
|
||||
}
|
||||
|
||||
/// `f` of each of `items`, in order, spread over the available cores.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len());
|
||||
parallel_map_at_most(usize::MAX, items, f)
|
||||
}
|
||||
|
||||
/// `f` of each of `items`, in order, spread over the available cores, at most `at_a_time` at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn parallel_map_at_most<T: Sync, R: Send>(at_a_time: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len()).min(at_a_time);
|
||||
if threads <= 1 {
|
||||
return items.iter().map(f).collect();
|
||||
}
|
||||
@@ -744,7 +873,7 @@ fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Ve
|
||||
|
||||
/// `f` of each of `items`, in order (no threads on the web).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
fn parallel_map_at_most<T: Sync, R: Send>(_: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
items.iter().map(f).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,41 @@ pub(crate) fn resample_closed(p: &[Point], step: f64) -> Option<(Vec<Point>, f64
|
||||
Some((out, total / m as f64))
|
||||
}
|
||||
|
||||
/// An open polyline resampled at (about) `step` spacing, both ends kept; also returns the exact
|
||||
/// spacing used.
|
||||
pub(crate) fn resample_open(p: &[Point], step: f64) -> Option<(Vec<Point>, f64)> {
|
||||
let n = p.len();
|
||||
let (first, last) = (*p.first()?, *p.last()?);
|
||||
if n < 2 || step.is_nan() || step <= 0.0 || !p.iter().all(|&q| finite(q)) {
|
||||
return None;
|
||||
}
|
||||
let mut cum = Vec::with_capacity(n);
|
||||
cum.push(0.0);
|
||||
for pair in p.windows(2) {
|
||||
cum.push(cum.last().copied().unwrap_or(0.0) + pair[0].distance(pair[1]));
|
||||
}
|
||||
let total = cum.last().copied().unwrap_or(0.0);
|
||||
if total.is_nan() || total <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let m = ((total / step).round() as usize).clamp(1, MAX_POINTS);
|
||||
let mut out = Vec::with_capacity(m + 1);
|
||||
out.push(first);
|
||||
let mut j = 0usize;
|
||||
for i in 1..m {
|
||||
let s = total * i as f64 / m as f64;
|
||||
while cum.get(j + 1).is_some_and(|&c| c < s) {
|
||||
j += 1;
|
||||
}
|
||||
let (c0, c1) = (*cum.get(j)?, *cum.get(j + 1)?);
|
||||
let t = if c1 > c0 { (s - c0) / (c1 - c0) } else { 0.0 };
|
||||
let (a, b) = (*p.get(j)?, *p.get(j + 1)?);
|
||||
out.push(a + (b - a) * t);
|
||||
}
|
||||
out.push(last);
|
||||
Some((out, total / m as f64))
|
||||
}
|
||||
|
||||
fn kernel(sigma: f64) -> Vec<f64> {
|
||||
let s = sigma.max(0.3);
|
||||
let r = ((3.0 * s).ceil() as i64).max(1);
|
||||
|
||||
@@ -24,7 +24,8 @@ mod tests;
|
||||
mod testutil;
|
||||
mod unmix;
|
||||
|
||||
pub(crate) use geom::{Cubic, all_within, resample_closed, sample_cubics};
|
||||
pub(crate) use outline::{Stats, Style, fit_outline};
|
||||
pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics};
|
||||
pub(crate) use outline::{Stats, Style, fit_open, fit_outline};
|
||||
pub(crate) use palette::parse_hex;
|
||||
pub(crate) use pipeline::trace_logo;
|
||||
pub(crate) use restore::RestoreParams;
|
||||
@@ -1,7 +1,9 @@
|
||||
//! One outline: smooth, restore corners, snap to lines and arcs, fit Béziers, and verify.
|
||||
|
||||
use super::bezier::{fit_cubics, tangent};
|
||||
use super::geom::{Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, sample_cubics, smooth_closed, smooth_open};
|
||||
use super::geom::{
|
||||
Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_open,
|
||||
};
|
||||
use super::restore::{RestoreParams, restore_corners};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
@@ -95,6 +97,105 @@ fn plain_fit(p: &[Point], tol: f64) -> Vec<Cubic> {
|
||||
fit_cubics(&closed, t, -t, tol)
|
||||
}
|
||||
|
||||
/// One run between two corners, added to `segs`: a line or an arc where it is one, else fitted
|
||||
/// curves. `sg` is the smoothing, in samples.
|
||||
fn fit_run(run: &[Point], sg: f64, st: &Style, stats: &mut Stats, segs: &mut Vec<Cubic>) {
|
||||
let (Some(&first), Some(&last)) = (run.first(), run.last()) else { return };
|
||||
if run.len() < 4 {
|
||||
if run.len() >= 2 && first.distance(last) > 1e-9 {
|
||||
segs.push(line_cubic(first, last));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sm = smooth_open(run, sg);
|
||||
let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { return };
|
||||
let len = a.distance(b);
|
||||
if st.snap && len > 1.5 {
|
||||
let d = b - a;
|
||||
let nrm = kurbo::Vec2::new(-d.y, d.x) / len;
|
||||
if sm.iter().all(|q| (*q - a).dot(nrm).abs() < st.line_tol) {
|
||||
stats.lines += 1;
|
||||
segs.push(line_cubic(a, b));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if st.snap
|
||||
&& st.arcs
|
||||
&& sm.len() > 12
|
||||
&& len > 1.0
|
||||
&& let Some(c) = circle_fit(&sm)
|
||||
&& c.resid < st.circle_tol
|
||||
&& c.r > 2.5
|
||||
&& c.r < 400.0
|
||||
&& let Some((a0, a1)) = sweep(&sm, c.c)
|
||||
&& (a1 - a0).abs() > 0.35
|
||||
{
|
||||
stats.arcs += 1;
|
||||
let first = segs.len();
|
||||
segs.extend(arc_cubics(c.c, c.r, a0, a1));
|
||||
// The fitted circle may miss the corners by up to the snap tolerance: end exactly on them.
|
||||
if let Some(s) = segs.get_mut(first) {
|
||||
s[0] = a;
|
||||
}
|
||||
if let Some(s) = segs.last_mut() {
|
||||
s[3] = b;
|
||||
}
|
||||
return;
|
||||
}
|
||||
segs.extend(fit_cubics(&sm, tangent(&sm, 0, 4, 1), tangent(&sm, sm.len() - 1, 4, -1), st.tol));
|
||||
}
|
||||
|
||||
/// The open run `p` as cubic Béziers from its first point to its last, both kept exactly: cut at
|
||||
/// its corners, each stretch a line, an arc or fitted curves as in a closed outline. (No corner is
|
||||
/// put back here: a piece of an outline between two junctions ends on pixel corners anyway.)
|
||||
pub(crate) fn fit_open(p: &[Point], st: &Style, stats: &mut Stats) -> Vec<Cubic> {
|
||||
let mut segs = Vec::new();
|
||||
let (Some(&first), Some(&last)) = (p.first(), p.last()) else { return segs };
|
||||
let whole = |segs: &mut Vec<Cubic>| {
|
||||
if first.distance(last) > 1e-9 {
|
||||
segs.push(line_cubic(first, last));
|
||||
}
|
||||
};
|
||||
let Some((r, step)) = resample_open(p, st.step) else {
|
||||
whole(&mut segs);
|
||||
return segs;
|
||||
};
|
||||
let n = r.len();
|
||||
let s0 = smooth_open(&r, (0.18 / step).max(1.0));
|
||||
let w = ((st.corner_win / step).round() as usize).max(2);
|
||||
let angle = |i: usize| {
|
||||
let (Some(&c), Some(&a), Some(&b)) = (s0.get(i), i.checked_sub(w).and_then(|j| s0.get(j)), s0.get(i + w)) else { return 0.0 };
|
||||
let (v1, v2) = (c - a, b - c);
|
||||
v1.cross(v2).atan2(v1.dot(v2)).abs().to_degrees()
|
||||
};
|
||||
let mut cuts = vec![0];
|
||||
for i in w..n.saturating_sub(w) {
|
||||
let here = angle(i);
|
||||
if here >= st.corner_deg && here >= angle(i - 1) && here > angle(i + 1) && cuts.last().is_none_or(|&l| i - l > w) {
|
||||
cuts.push(i);
|
||||
}
|
||||
}
|
||||
cuts.push(n - 1);
|
||||
let sg = st.sigma / step;
|
||||
for pair in cuts.windows(2) {
|
||||
let Some(run) = r.get(pair[0]..=pair[1]) else { continue };
|
||||
let before = segs.len();
|
||||
fit_run(run, sg, st, stats, &mut segs);
|
||||
// Every stretch must lead on to the next.
|
||||
if segs.len() == before
|
||||
&& let (Some(&a), Some(&b)) = (run.first(), run.last())
|
||||
&& a.distance(b) > 1e-9
|
||||
{
|
||||
segs.push(line_cubic(a, b));
|
||||
}
|
||||
}
|
||||
if !sane(&segs) {
|
||||
segs.clear();
|
||||
whole(&mut segs);
|
||||
}
|
||||
segs
|
||||
}
|
||||
|
||||
fn styled_fit(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>> {
|
||||
let (mut r, step) = resample_closed(p, st.step)?;
|
||||
let sg = st.sigma / step;
|
||||
@@ -147,49 +248,7 @@ fn styled_fit(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>>
|
||||
let i1 = *corners.get((ci + 1) % corners.len())?;
|
||||
let count = if i1 <= i0 { i1 + n - i0 } else { i1 - i0 } + 1;
|
||||
let run: Vec<Point> = (0..count).filter_map(|k| r.get((i0 + k) % n).copied()).collect();
|
||||
let (Some(&first), Some(&last)) = (run.first(), run.last()) else { continue };
|
||||
if run.len() < 4 {
|
||||
if run.len() >= 2 && first.distance(last) > 1e-9 {
|
||||
segs.push(line_cubic(first, last));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let sm = smooth_open(&run, sg);
|
||||
let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { continue };
|
||||
let len = a.distance(b);
|
||||
if st.snap && len > 1.5 {
|
||||
let d = b - a;
|
||||
let nrm = kurbo::Vec2::new(-d.y, d.x) / len;
|
||||
if sm.iter().all(|q| (*q - a).dot(nrm).abs() < st.line_tol) {
|
||||
stats.lines += 1;
|
||||
segs.push(line_cubic(a, b));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if st.snap
|
||||
&& st.arcs
|
||||
&& sm.len() > 12
|
||||
&& len > 1.0
|
||||
&& let Some(c) = circle_fit(&sm)
|
||||
&& c.resid < st.circle_tol
|
||||
&& c.r > 2.5
|
||||
&& c.r < 400.0
|
||||
&& let Some((a0, a1)) = sweep(&sm, c.c)
|
||||
&& (a1 - a0).abs() > 0.35
|
||||
{
|
||||
stats.arcs += 1;
|
||||
let first = segs.len();
|
||||
segs.extend(arc_cubics(c.c, c.r, a0, a1));
|
||||
// The fitted circle may miss the corners by up to the snap tolerance: end exactly on them.
|
||||
if let Some(s) = segs.get_mut(first) {
|
||||
s[0] = a;
|
||||
}
|
||||
if let Some(s) = segs.last_mut() {
|
||||
s[3] = b;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segs.extend(fit_cubics(&sm, tangent(&sm, 0, 4, 1), tangent(&sm, sm.len() - 1, 4, -1), st.tol));
|
||||
fit_run(&run, sg, st, stats, &mut segs);
|
||||
}
|
||||
(!segs.is_empty()).then_some(segs)
|
||||
}
|
||||
|
||||
+192
-44
@@ -40,6 +40,10 @@ pub(crate) struct Crack {
|
||||
}
|
||||
|
||||
impl Crack {
|
||||
/// The crack between pixel `inside` and the pixel `step` (one pixel right or down) from it.
|
||||
pub fn new(inside: (i32, i32), step: (i32, i32), w: usize, h: usize) -> Self {
|
||||
Self { inside, step, w, h }
|
||||
}
|
||||
/// The index of the pixel `k` steps out from the inside pixel (0: the inside pixel, 1: the
|
||||
/// outside one, −1: the one behind the inside pixel), if the image has it.
|
||||
pub fn at(&self, k: i32) -> Option<usize> {
|
||||
@@ -52,20 +56,58 @@ fn pixel(p: (i32, i32), w: usize, h: usize) -> Option<usize> {
|
||||
(x < w && y < h).then(|| y * w + x)
|
||||
}
|
||||
|
||||
/// The outline of `l` (a loop of a `w × h` image) as points, and whether its edge is hard.
|
||||
/// A stretch of an outline with one neighbour along it: its points, and whether its edge is hard.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Piece {
|
||||
pub pts: Vec<Point>,
|
||||
pub hard: bool,
|
||||
/// The piece is a whole loop without a junction.
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// What lies across a crack on the image's border: one neighbour per side, so that the image's
|
||||
/// corners are junctions and stay exact.
|
||||
fn border(dir: (i32, i32)) -> u32 {
|
||||
u32::MAX
|
||||
- match dir {
|
||||
(1, 0) => 0,
|
||||
(0, 1) => 1,
|
||||
(-1, 0) => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline of `l` (a loop of a `w × h` image) as points.
|
||||
///
|
||||
/// `shift` says how far (in pixels, −0.5 to 0.5, positive towards the outside pixel) the edge lies
|
||||
/// from a crack.
|
||||
pub(crate) fn outline(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec<Point>, bool) {
|
||||
/// `across` says, for a crack, how far (in pixels, −0.5 to 0.5, positive towards the outside
|
||||
/// pixel) the edge lies from it, and which region is on its other side.
|
||||
///
|
||||
/// The outline comes in pieces, cut at its junctions: the pixel corners where the region on the
|
||||
/// other side changes (three or more regions meet there, or the image's border begins), and
|
||||
/// those where two parts of a region touch by a corner only (there its outline and its
|
||||
/// neighbour's part ways without the neighbour changing). `junction_at` says of a pixel corner
|
||||
/// whether it is a junction of that second kind, when asked without `must`, and where the
|
||||
/// junction really lies, the same for every region around it. A piece
|
||||
/// is the whole edge between two regions, and both get exactly the same points for it (a crack
|
||||
/// gives the same point from either side, shifts are rounded to 1/1024 px so that they agree to
|
||||
/// the last bit, and whether a piece is hard is decided by the piece alone), so that fitted the
|
||||
/// same way it is one curve for both. A loop without junctions is one closed piece.
|
||||
pub(crate) fn outline(
|
||||
l: &Loop,
|
||||
w: usize,
|
||||
h: usize,
|
||||
across: &dyn Fn(Crack) -> (f64, u32),
|
||||
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
|
||||
) -> Vec<Piece> {
|
||||
let v = &l.pts;
|
||||
let n = v.len();
|
||||
let at = |i: usize| Point::new(f64::from(v[i % n].0), f64::from(v[i % n].1));
|
||||
let raw = || (0..n).map(at).collect::<Vec<_>>();
|
||||
if n < 4 {
|
||||
return (raw(), true);
|
||||
return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }];
|
||||
}
|
||||
let mut edges = Vec::with_capacity(n);
|
||||
let mut shifts: Vec<f64> = Vec::new();
|
||||
// Per crack: its shift, the region across it, and the edge it is on.
|
||||
let (mut shifts, mut others, mut edge_of): (Vec<f64>, Vec<u32>, Vec<usize>) = (vec![], vec![], vec![]);
|
||||
for i in 0..n {
|
||||
let (a, b) = (v[i], v[(i + 1) % n]);
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
@@ -82,43 +124,95 @@ pub(crate) fn outline(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64
|
||||
_ => ((sx, sy - 1), (sx - 1, sy - 1)),
|
||||
};
|
||||
let crack = Crack { inside, step: (outside.0 - inside.0, outside.1 - inside.1), w, h };
|
||||
let s = match crack.at(1) {
|
||||
Some(_) => shift(crack),
|
||||
let (s, other) = match crack.at(1) {
|
||||
Some(_) => across(crack),
|
||||
// The edge of the image is where it is.
|
||||
_ => 0.0,
|
||||
_ => (0.0, border(dir)),
|
||||
};
|
||||
shifts.push(if s.is_finite() { s.clamp(-0.5, 0.5) } else { 0.0 });
|
||||
shifts.push(if s.is_finite() { (s.clamp(-0.5, 0.5) * 1024.0).round() / 1024.0 } else { 0.0 });
|
||||
others.push(other);
|
||||
edge_of.push(i);
|
||||
}
|
||||
}
|
||||
// A hard edge: nearly every crack moves by the same amount.
|
||||
let mut sorted = shifts.clone();
|
||||
let mid = sorted.len() / 2;
|
||||
let median = if sorted.is_empty() { 0.0 } else { *sorted.select_nth_unstable_by(mid, f64::total_cmp).1 };
|
||||
let hard = shifts.iter().filter(|s| (*s - median).abs() > 0.03).count() * 4 < shifts.len();
|
||||
let cracks = shifts.len();
|
||||
if cracks == 0 {
|
||||
return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }];
|
||||
}
|
||||
// The pixel corner each crack starts at.
|
||||
let corner_of = |c: usize| {
|
||||
let (i, e) = (edge_of[c], &edges[edge_of[c]]);
|
||||
(v[i].0 + e.dir.0 * (c - e.first) as i32, v[i].1 + e.dir.1 * (c - e.first) as i32)
|
||||
};
|
||||
// A junction lies at the start of each crack whose neighbour is not the crack before's, and
|
||||
// where the callback says one is.
|
||||
let junctions: Vec<Option<Point>> = (0..cracks)
|
||||
.map(|c| {
|
||||
let (x, y) = corner_of(c);
|
||||
let changed = others[c] != others[(c + cracks - 1) % cracks];
|
||||
junction_at(x, y).or_else(|| changed.then(|| Point::new(f64::from(x), f64::from(y))))
|
||||
})
|
||||
.collect();
|
||||
let junction = |c: usize| junctions[c].is_some();
|
||||
let first = (0..cracks).find(|&c| junction(c));
|
||||
// A hard edge: nearly every crack of the piece moves by the same amount. (The middle of the
|
||||
// two middle shifts, so that the piece is as hard read from its other side, shifts negated.)
|
||||
let is_hard = |piece: &[f64]| {
|
||||
let mut sorted = piece.to_vec();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let median = (sorted[sorted.len() / 2] + sorted[(sorted.len() - 1) / 2]) / 2.0;
|
||||
piece.iter().filter(|s| (*s - median).abs() > 0.03).count() * 4 < piece.len()
|
||||
};
|
||||
|
||||
// Which way the loop turns at vertex `i`, and whether edge `i` is a step of a staircase: one
|
||||
// pixel long, turning one way where it starts and the other where it ends.
|
||||
// (At a junction the loop goes its own way, its neighbour another: a turn there says nothing
|
||||
// about the edge they share, and counts as none.)
|
||||
let turn = |i: usize| {
|
||||
let (a, b) = (edges[(i + n - 1) % n].dir, edges[i % n].dir);
|
||||
(a.0 * b.1 - a.1 * b.0).signum()
|
||||
if junctions[edges[i % n].first].is_some() { 0 } else { (a.0 * b.1 - a.1 * b.0).signum() }
|
||||
};
|
||||
let step: Vec<bool> = (0..n).map(|i| edges[i].len == 1 && turn(i) * turn(i + 1) < 0).collect();
|
||||
let outward = |d: (i32, i32)| Vec2::new(f64::from(d.1), f64::from(-d.0));
|
||||
let shift_of = |e: &Edge, k: usize| shifts.get(e.first + k).copied().unwrap_or(0.0);
|
||||
|
||||
let mut out = Vec::with_capacity(if hard { n } else { shifts.len() + n });
|
||||
for i in 0..n {
|
||||
let (before, e) = (&edges[(i + n - 1) % n], &edges[i]);
|
||||
if !step[i] && !step[(i + n - 1) % n] {
|
||||
// A true corner: where its two edges, each moved by its own shift, meet.
|
||||
out.push(at(i) + outward(before.dir) * shift_of(before, before.len.saturating_sub(1)) + outward(e.dir) * shift_of(e, 0));
|
||||
let mut pieces: Vec<Piece> = Vec::new();
|
||||
let start = first.unwrap_or(0);
|
||||
let mut c = 0;
|
||||
while c < cracks {
|
||||
// The cracks of one piece: up to the next junction.
|
||||
let len = (1..cracks - c).find(|&k| junction((start + c + k) % cracks)).unwrap_or(cracks - c);
|
||||
let own: Vec<f64> = (0..len).map(|k| shifts[(start + c + k) % cracks]).collect();
|
||||
let hard = is_hard(&own);
|
||||
let mut pts = Vec::with_capacity(len + 2);
|
||||
for k in 0..len {
|
||||
let crack = (start + c + k) % cracks;
|
||||
let i = edge_of[crack];
|
||||
let (e, along_edge) = (&edges[i], crack - edges[i].first);
|
||||
let corner = at(i) + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * along_edge as f64;
|
||||
if k == 0 && first.is_some() {
|
||||
// The junction itself: the one point every region around it has.
|
||||
pts.push(junctions[crack].unwrap_or(corner));
|
||||
} else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] {
|
||||
// A true corner: where its two edges, each moved by its own shift, meet.
|
||||
let before = &edges[(i + n - 1) % n];
|
||||
pts.push(corner + outward(before.dir) * shifts[(crack + cracks - 1) % cracks] + outward(e.dir) * shifts[crack]);
|
||||
}
|
||||
if step[i] || !hard {
|
||||
pts.push(corner + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * 0.5 + outward(e.dir) * shifts[crack]);
|
||||
}
|
||||
}
|
||||
if step[i] || !hard {
|
||||
let along = Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1));
|
||||
out.extend((0..e.len).map(|k| at(i) + along * (k as f64 + 0.5) + outward(e.dir) * shift_of(e, k)));
|
||||
if first.is_some() {
|
||||
// It ends on the next junction: the corner the next piece starts on.
|
||||
let next = (start + c + len) % cracks;
|
||||
let (x, y) = corner_of(next);
|
||||
pts.push(junctions[next].unwrap_or(Point::new(f64::from(x), f64::from(y))));
|
||||
}
|
||||
pieces.push(Piece { pts, hard, closed: first.is_none() });
|
||||
c += len;
|
||||
}
|
||||
if out.len() < 3 { (raw(), hard) } else { (out, hard) }
|
||||
if first.is_none() && pieces.first().is_some_and(|p| p.pts.len() < 3) {
|
||||
return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }];
|
||||
}
|
||||
pieces
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -130,11 +224,27 @@ mod tests {
|
||||
trace_mask(mask, w, h).remove(0).outer
|
||||
}
|
||||
|
||||
/// No junctions but where the neighbour changes, and those on the pixel grid.
|
||||
fn grid(_: i32, _: i32) -> Option<Point> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The outline with one neighbour all round (the image's border too): its points, and whether
|
||||
/// it is hard.
|
||||
fn whole(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec<Point>, bool) {
|
||||
let mut pieces = outline(l, w, h, &|c| (shift(c), 0), &grid);
|
||||
let hard = pieces.iter().all(|p| p.hard);
|
||||
// Only the image's border makes junctions here: drop each piece's closing point.
|
||||
let split = pieces.len() > 1;
|
||||
let pts = pieces.iter_mut().flat_map(|p| p.pts.drain(..p.pts.len() - usize::from(split))).collect();
|
||||
(pts, hard)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_rectangle_keeps_its_four_corners() {
|
||||
let (w, h) = (8, 6);
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| (2..6).contains(&(i % w)) && (1..5).contains(&(i / w))).collect();
|
||||
let (pts, hard) = outline(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
let (pts, hard) = whole(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
assert!(hard);
|
||||
assert_eq!(pts, [(2.0, 1.0), (6.0, 1.0), (6.0, 5.0), (2.0, 5.0)].map(Point::from));
|
||||
}
|
||||
@@ -144,7 +254,7 @@ mod tests {
|
||||
// Below the line y = x / 4 + 2: treads four pixels long.
|
||||
let (w, h) = (16, 8);
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| (i / w) as f64 + 0.5 > (i % w) as f64 / 4.0 + 2.0).collect();
|
||||
let (pts, hard) = outline(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
let (pts, hard) = whole(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
assert!(hard);
|
||||
// Every step's middle lies on one line, a quarter of a pixel's rise per pixel.
|
||||
let steps: Vec<Point> = pts.iter().copied().filter(|p| p.y.fract() == 0.5 && p.x > 0.0 && p.x < w as f64).collect();
|
||||
@@ -157,28 +267,66 @@ mod tests {
|
||||
let (w, h) = (6, 4);
|
||||
// The left half is inside; its right edge really lies 0.3 px further right.
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| i % w < 3).collect();
|
||||
let shift = |c: Crack| if c.at(0).is_some_and(|i| i % w == 2) && c.at(1).is_some_and(|i| i % w == 3) { 0.3 } else { 0.0 };
|
||||
// (0.1 px in the lower rows: a shift that is the same all along makes a hard edge.)
|
||||
let shift = |c: Crack| match (c.at(0), c.at(1)) {
|
||||
(Some(a), Some(b)) if a % w == 2 && b % w == 3 => {
|
||||
if a / w < 2 {
|
||||
0.3
|
||||
} else {
|
||||
0.1
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let l = loop_of(&mask, w, h);
|
||||
let (pts, hard) = outline(&l, w, h, &shift);
|
||||
assert!(!hard);
|
||||
let (pts, _) = whole(&l, w, h, &shift);
|
||||
// Each crack comes with the column of pixels across it.
|
||||
outline(&l, w, h, &|c| {
|
||||
let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap());
|
||||
assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1)));
|
||||
assert_eq!((c.at(-3), c.at(4)), (None, None));
|
||||
0.0
|
||||
});
|
||||
let right: Vec<&Point> = pts.iter().filter(|p| p.x > 2.5).collect();
|
||||
// The four cracks' middles and the two corners, all at x = 3.3.
|
||||
assert_eq!(right.len(), 6);
|
||||
assert!(right.iter().all(|p| (p.x - 3.3).abs() < 1e-12), "{right:?}");
|
||||
outline(
|
||||
&l,
|
||||
w,
|
||||
h,
|
||||
&|c| {
|
||||
let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap());
|
||||
assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1)));
|
||||
assert_eq!((c.at(-3), c.at(4)), (None, None));
|
||||
(0.0, 0)
|
||||
},
|
||||
&grid,
|
||||
);
|
||||
// The four cracks' middles, at x = 3.3 (rounded to 1/1024); the edge ends on the image's
|
||||
// border, at junctions on the pixel grid.
|
||||
let right: Vec<&Point> = pts.iter().filter(|p| p.x > 3.05).collect();
|
||||
assert_eq!(right.len(), 4);
|
||||
assert!(right.iter().all(|p| (p.x - if p.y < 2.0 { 3.3 } else { 3.1 }).abs() < 1e-3), "{right:?}");
|
||||
assert!(pts.contains(&Point::new(3.0, 0.0)) && pts.contains(&Point::new(3.0, 4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shift_that_is_not_a_number_moves_nothing() {
|
||||
let mask = [true, true, false, false, true, true, false, false];
|
||||
let (pts, _) = outline(&loop_of(&mask, 4, 2), 4, 2, &|_| f64::NAN);
|
||||
let (pts, _) = whole(&loop_of(&mask, 4, 2), 4, 2, &|_| f64::NAN);
|
||||
assert_eq!(pts.len(), 4);
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_regions_get_the_same_points_for_their_shared_edge() {
|
||||
// Left and right halves meet along a staircase; each sees the other's shifts negated.
|
||||
let (w, h) = (12, 10);
|
||||
let left: Vec<bool> = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect();
|
||||
let right: Vec<bool> = left.iter().map(|b| !b).collect();
|
||||
let wave = |c: Crack| 0.4 * ((c.at(0).unwrap() + c.at(1).unwrap()) as f64).sin();
|
||||
let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1), &grid);
|
||||
let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2), &grid);
|
||||
// The piece that is not on the image's border, from either side.
|
||||
let inner = |pieces: &[Piece]| {
|
||||
pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone()
|
||||
};
|
||||
let (mut ours, theirs) = (inner(&a), inner(&b));
|
||||
ours.pts.reverse();
|
||||
assert_eq!(ours, theirs);
|
||||
assert!(ours.pts.len() > 12);
|
||||
// Each side of the image is a piece of its own: its corners are exact.
|
||||
assert!(a.iter().chain(&b).all(|p| p.pts.first().is_some_and(|q| q.x.fract() == 0.0 && q.y.fract() == 0.0)));
|
||||
}
|
||||
}
|
||||
+108
-1
@@ -255,7 +255,7 @@ fn snap_curves_to_lines_straightens_near_lines() {
|
||||
|
||||
#[test]
|
||||
fn presets_resolve() {
|
||||
assert_eq!(presets().len(), 13);
|
||||
assert_eq!(presets().len(), 15);
|
||||
for n in PRESET_NAMES {
|
||||
assert!(preset(n).is_some(), "{n}");
|
||||
}
|
||||
@@ -986,3 +986,110 @@ fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() {
|
||||
assert!(b.x1 <= 20.5 || b.x0 >= 39.5, "a {} layer covers the white stripe: {b:?}", p.color[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cubic pieces of a path: (from, handle, handle, to).
|
||||
fn pieces_of(p: &vectorcraft_geom::PathData) -> Vec<[Point; 4]> {
|
||||
p.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| {
|
||||
(0..sp.segment_count()).map(move |i| {
|
||||
let c = sp.segment(i);
|
||||
[c.p0, c.p1, c.p2, c.p3]
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abutting_regions_share_one_curve_along_their_edge() {
|
||||
// A disc of two halves on a field: three colours, two junctions where all three meet.
|
||||
let color = |x: f64, y: f64| {
|
||||
if Point::new(x, y).distance(Point::new(50.3, 49.6)) > 35.2 {
|
||||
[255.0, 255.0, 255.0]
|
||||
} else if x < 50.3 + 0.2 * (y - 50.0) {
|
||||
[200.0, 40.0, 50.0]
|
||||
} else {
|
||||
[30.0, 90.0, 200.0]
|
||||
}
|
||||
};
|
||||
let img = Raster::from_fn(100, 100, |x, y| {
|
||||
let mut sum = [0.0f64; 3];
|
||||
for i in 0..16 {
|
||||
let c = color(f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0);
|
||||
for k in 0..3 {
|
||||
sum[k] += c[k] / 16.0;
|
||||
}
|
||||
}
|
||||
[sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255]
|
||||
});
|
||||
let res = trace(&img, &TraceParams { mode: Mode::Color, colors: 3, ..TraceParams::default() });
|
||||
assert_eq!(res.paths.len(), 3);
|
||||
let all: Vec<Vec<[Point; 4]>> = res.paths.iter().map(|p| pieces_of(&p.path)).collect();
|
||||
// Every curve that is not on the image's border is another path's curve too, run the other way.
|
||||
let inner = |c: &[Point; 4]| c.iter().all(|p| p.x > 0.5 && p.x < 99.5 && p.y > 0.5 && p.y < 99.5);
|
||||
let mut shared = 0;
|
||||
for (i, path) in all.iter().enumerate() {
|
||||
for c in path.iter().filter(|c| inner(c)) {
|
||||
let back = [c[3], c[2], c[1], c[0]];
|
||||
assert!(all.iter().enumerate().any(|(j, other)| j != i && other.contains(&back)), "path {i} has an edge of its own: {c:?}");
|
||||
shared += 1;
|
||||
}
|
||||
}
|
||||
assert!(shared >= 12, "{shared} shared curves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_traced_a_few_at_a_time_come_out_the_same() {
|
||||
let items: Vec<usize> = (0..40).collect();
|
||||
for at_a_time in [1, 3, usize::MAX] {
|
||||
assert_eq!(parallel_map_at_most(at_a_time, &items, |i| i * i), items.iter().map(|i| i * i).collect::<Vec<_>>());
|
||||
}
|
||||
// A layer is traced within the box of its pixels: a shape far from the image's origin stays there.
|
||||
let img = Raster::from_fn(90, 70, |x, y| if (60..80).contains(&x) && (40..60).contains(&y) { BLACK } else { WHITE });
|
||||
let b = trace(&img, &bw()).paths[0].path.bounds().unwrap();
|
||||
assert!([(b.x0, 60.0), (b.y0, 40.0), (b.x1, 80.0), (b.y1, 60.0)].iter().all(|(got, want)| (got - want).abs() < 0.01), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blurred_thin_line_on_unevenly_lit_paper_is_a_few_anchors() {
|
||||
// A line 2 px wide a little off the pixel rows, blurred over three pixels, on paper that is a
|
||||
// quarter darker at one end than at the other: nowhere is its ink pure black, or the paper white.
|
||||
let img = Raster::from_fn(160, 40, |x, y| {
|
||||
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let paper = 235.0 - 55.0 * fx / 160.0;
|
||||
let centre = 17.3 + 0.06 * fx;
|
||||
// The share of ink in the pixel, the line's edges blurred linearly over 1.5 px either way.
|
||||
let ink = if (10.0..150.0).contains(&fx) { ((1.0 + 1.5 - (fy - centre).abs()) / 3.0).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let v = (paper * (1.0 - ink) + 40.0 * ink).round() as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let res = trace(&img, &preset("Line Art").unwrap());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
let path = &res.paths[0].path;
|
||||
// Its two long edges are smooth: a ripple would take an anchor every few pixels.
|
||||
assert!(path.anchor_count() <= 10, "{} anchors", path.anchor_count());
|
||||
// And it lies along the line's middle, about as wide as its ink.
|
||||
let off = along(path).iter().filter(|p| p.x > 14.0 && p.x < 146.0).map(|p| ((p.y - (17.3 + 0.06 * p.x)).abs() - 1.0).abs()).fold(0.0, f64::max);
|
||||
assert!(off < 0.5, "edges {off} px from where the ink's width puts them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_color_leaves_out_the_colour_picked() {
|
||||
// Navy field, yellow disc, green bar.
|
||||
let img = three_colour_art(0.0);
|
||||
let params = TraceParams { colors: 3, ignore_white: true, ..sixteen_colors() };
|
||||
// White by default: nothing here is white.
|
||||
assert_eq!(trace(&img, ¶ms).paths.len(), 3);
|
||||
// The navy picked a little off: the field goes, the disc and the bar stay.
|
||||
let res = trace(&img, &TraceParams { ignore_color: Some("#18263c".replace("3c", "5e")), ..params.clone() });
|
||||
assert_eq!(res.paths.len(), 2);
|
||||
assert!(res.paths.iter().all(|p| p.color != [20, 40, 90]));
|
||||
// Not ticked, the colour is not left out; and a colour that is none is refused.
|
||||
assert_eq!(trace(&img, &TraceParams { ignore_white: false, ignore_color: Some("#14285a".into()), ..params.clone() }).paths.len(), 3);
|
||||
let bad = trace_within(&img, &TraceParams { ignore_color: Some("navy".into()), ..params.clone() }, usize::MAX);
|
||||
assert!(matches!(bad, Err(TraceError::BadPalette(_))));
|
||||
// Old documents have no such setting: white.
|
||||
let old: TraceParams = serde_json::from_str(r#"{"mode":"color","ignoreWhite":true}"#).unwrap();
|
||||
assert_eq!(old.ignore_color, None);
|
||||
assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null);
|
||||
}
|
||||
@@ -68,10 +68,10 @@ fn target(preset: &str, input: &Input) -> bool {
|
||||
"High Fidelity Photo" | "Low Fidelity Photo" | "Shades of Gray" => &[Class::Tone],
|
||||
// Three colours cannot show the badge's five.
|
||||
"3 Colors" => return input.name == "two touching halves",
|
||||
"6 Colors" | "16 Colors" | "Flat Logo" => &[Class::Flat],
|
||||
"6 Colors" | "16 Colors" | "Flat Logo" | "Auto Color" => &[Class::Flat],
|
||||
"Sketched Art" => &[Class::Shape, Class::Lines],
|
||||
"Silhouettes" => &[Class::Shape],
|
||||
"Line Art" => &[Class::Lines],
|
||||
"Line Art" | "Outline" => &[Class::Lines],
|
||||
"Technical Drawing" => &[Class::Lines, Class::Poly],
|
||||
_ => &[Class::Shape, Class::Poly],
|
||||
};
|
||||
|
||||
@@ -739,7 +739,7 @@ mod tests {
|
||||
let fr = crate::i18n::Lang::from_code("fr").unwrap();
|
||||
let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB");
|
||||
assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}");
|
||||
for code in ["de", "es", "fr", "hu", "it", "ru"] {
|
||||
for code in ["de", "es", "fr", "hu", "it", "pl", "ru"] {
|
||||
let lang = crate::i18n::Lang::from_code(code).unwrap();
|
||||
for s in [
|
||||
"every search thread stopped on an internal error (please report this bug)",
|
||||
|
||||
@@ -155,6 +155,9 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &
|
||||
} else {
|
||||
heading(ui, tl!("Object"));
|
||||
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
|
||||
number(ui, d, "twist", tl!("Twist"), (0.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "taper", tl!("Taper"), (1.0, 100.0), "%", 1, None);
|
||||
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
|
||||
number(ui, d, "volume", tl!("Volume"), (0.0, 100.0), "%", 1, None);
|
||||
toggle(ui, d, "bothSides", tl!("Both sides"), false);
|
||||
}
|
||||
@@ -167,7 +170,7 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &
|
||||
fn extrude_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
heading(ui, tl!("Object"));
|
||||
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
|
||||
number(ui, d, "twist", tl!("Twist"), (-360.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "twist", tl!("Twist"), (0.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "taper", tl!("Taper"), (1.0, 100.0), "%", 1, None);
|
||||
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
|
||||
ui.add_space(10.0);
|
||||
|
||||
@@ -213,7 +213,7 @@ fn the_other_3d_effects_show_their_options_and_position_presets() {
|
||||
let square = "M100 100 L200 100 L200 200 L100 200 Z";
|
||||
for (effect, own) in [
|
||||
(EXTRUDE, &["Depth", "Twist", "Taper", "Cap", "Bevel", "Bevel Shape", "Space", "Bevel Inside", "Bevel both sides"][..]),
|
||||
(INFLATE, &["Depth", "Volume", "Both sides"][..]),
|
||||
(INFLATE, &["Depth", "Twist", "Taper", "Cap", "Volume", "Both sides"][..]),
|
||||
(ROTATE, &[][..]),
|
||||
] {
|
||||
let mut h = Harness::with(square, effect, vec2(1400.0, 1000.0));
|
||||
|
||||
@@ -621,6 +621,7 @@
|
||||
VectorCraft on GitHub VectorCraft na GitHubu
|
||||
Search Commands… Hledat příkazy…
|
||||
VectorCraft Help… Nápověda VectorCraft…
|
||||
Auto Color Automatická barva
|
||||
Automatic Automaticky
|
||||
Isolate Selected Group Izolovat vybranou skupinu
|
||||
Exit Isolation Mode Ukončit izolovaný režim
|
||||
@@ -1424,6 +1425,7 @@ lighting Light Světlo
|
||||
Abutting Sousedící
|
||||
Black and White Černobílé
|
||||
Corners Rohy
|
||||
Ignore Color Ignorovat barvu
|
||||
Ignore White Ignorovat bílou
|
||||
Palette: Paleta:
|
||||
Limited Omezená
|
||||
@@ -3660,6 +3662,7 @@ effect Regular Pravidelný
|
||||
Stroke Pressure Tlak tahu
|
||||
|
||||
# Plurals: one|few|other
|
||||
imageTrace Outline Obrys
|
||||
@plural {n} item|{n} items {n} položka|{n} položky|{n} položek
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Tabulka barev ({n} barva)|Tabulka barev ({n} barvy)|Tabulka barev ({n} barev)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Odstranit {n} grafický styl?|Odstranit {n} grafické styly?|Odstranit {n} grafických stylů?
|
||||
@@ -4683,3 +4686,5 @@ mark-style Japanese Japonské
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? neznámý parametr `{key}`; mysleli jste `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters neznámý parametr `{key}`; příkaz nemá žádné parametry
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys}
|
||||
Load EPS into Clipboard Načíst EPS do schránky
|
||||
@msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -229,6 +229,7 @@
|
||||
Author Title: Titel des Autors:
|
||||
Author: Autor:
|
||||
Auto Auto
|
||||
Auto Color Automatische Farbe
|
||||
Automatic Automatisch
|
||||
Auto Size New Area Type Größe von neuem Flächentext automatisch anpassen
|
||||
Auto Spell Check Automatische Rechtschreibprüfung
|
||||
@@ -1226,6 +1227,7 @@
|
||||
Identify Objects By Objekte identifizieren nach
|
||||
Ignore Ignorieren
|
||||
Ignore Artboards (all the art as one page) Zeichenflächen ignorieren (gesamte Grafik als eine Seite)
|
||||
Ignore Color Farbe ignorieren
|
||||
Ignore White Weiß ignorieren
|
||||
Palette: Palette:
|
||||
Limited Begrenzt
|
||||
@@ -3135,6 +3137,7 @@ effect Points Punkte
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}: Klicken korrigiert auf die nächstliegende Farbe ({hex})
|
||||
“{file}” is protected by a password. „{file}“ ist durch ein Kennwort geschützt.
|
||||
● Recording ● Aufzeichnung
|
||||
imageTrace Outline Kontur
|
||||
@plural {n} item|{n} items {n} Element|{n} Elemente
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Farbtabelle ({n} Farbe)|Farbtabelle ({n} Farben)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Diesen {n} Grafikstil löschen?|Diese {n} Grafikstile löschen?
|
||||
@@ -4206,3 +4209,5 @@ theme Appearance Erscheinungsbild
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? unbekannter Parameter `{key}`; meinten Sie `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters unbekannter Parameter `{key}`; der Befehl hat keine Parameter
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys}
|
||||
Load EPS into Clipboard EPS in die Zwischenablage laden
|
||||
@msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -247,6 +247,7 @@
|
||||
Author Title: Cargo del autor:
|
||||
Author: Autor:
|
||||
Auto Auto
|
||||
Auto Color Color automático
|
||||
Automatic Automático
|
||||
Auto Size New Area Type Ajustar automáticamente el tamaño del nuevo texto de área
|
||||
Auto Spell Check Revisión ortográfica automática
|
||||
@@ -1244,6 +1245,7 @@
|
||||
Identify Objects By Identificar objetos por
|
||||
Ignore Ignorar
|
||||
Ignore Artboards (all the art as one page) Ignorar mesas de trabajo (toda la ilustración como una página)
|
||||
Ignore Color Ignorar color
|
||||
Ignore White Ignorar blanco
|
||||
Palette: Paleta:
|
||||
Limited Limitada
|
||||
@@ -3236,6 +3238,7 @@
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}: clic para corregir al color más cercano ({hex})
|
||||
“{file}” is protected by a password. “{file}” está protegido con contraseña.
|
||||
● Recording ● Grabando
|
||||
imageTrace Outline Contorno
|
||||
@plural {n} item|{n} items {n} elemento|{n} elementos
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Tabla de colores ({n} color)|Tabla de colores ({n} colores)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? ¿Eliminar {n} estilo gráfico?|¿Eliminar estos {n} estilos gráficos?
|
||||
@@ -4623,3 +4626,5 @@ mark-style Japanese Japonesas
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parámetro desconocido `{key}`; ¿quiso decir `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parámetro desconocido `{key}`; el comando no tiene parámetros
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys}
|
||||
Load EPS into Clipboard Cargar EPS en el portapapeles
|
||||
@msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
+608
-180
File diff suppressed because it is too large.
Load diff
@@ -360,6 +360,7 @@
|
||||
Auto Spell Check Automatikus helyesírás-ellenőrzés
|
||||
Auto-Collapse Iconic Panels Ikonpanelek automatikus becsukása
|
||||
Auto-Rotate (turn the paper to each artboard) Automatikus elforgatás (a papír igazodik minden rajztáblához)
|
||||
Auto Color Automatikus szín
|
||||
Automatic Automatikus
|
||||
Automatic flatness Automatikus simaság
|
||||
Automatically Automatikusan
|
||||
@@ -1608,6 +1609,7 @@
|
||||
Identify Objects By Objektumok azonosítása
|
||||
Ignore Kihagy
|
||||
Ignore Artboards (all the art as one page) Rajztáblák figyelmen kívül hagyása (az összes grafika egy oldalon)
|
||||
Ignore Color Szín figyelmen kívül hagyása
|
||||
Ignore White Fehér figyelmen kívül hagyása
|
||||
Image Kép
|
||||
Image Info Kép adatai
|
||||
@@ -3935,6 +3937,7 @@ lighting Light Fény
|
||||
theme Appearance Kinézet
|
||||
|
||||
# Plurals: one|other
|
||||
imageTrace Outline Körvonal
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Színtábla ({n} szín)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? {n} grafikai stílus törlése?
|
||||
@plural {n} area highlighted|{n} areas highlighted Kiemelt terület: {n}
|
||||
@@ -4649,3 +4652,5 @@ theme Appearance Kinézet
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? ismeretlen paraméter: `{key}`; erre gondolt: `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters ismeretlen paraméter: `{key}`; a parancsnak nincs paramétere
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys}
|
||||
Load EPS into Clipboard EPS betöltése a vágólapra
|
||||
@msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -247,6 +247,7 @@
|
||||
Author Title: Qualifica dell'autore:
|
||||
Author: Autore:
|
||||
Auto Auto
|
||||
Auto Color Colore automatico
|
||||
Automatic Automatico
|
||||
Auto Size New Area Type Dimensionamento automatico del nuovo testo in area
|
||||
Auto Spell Check Controllo ortografico automatico
|
||||
@@ -1243,6 +1244,7 @@
|
||||
Identify Objects By Identifica oggetti per
|
||||
Ignore Ignora
|
||||
Ignore Artboards (all the art as one page) Ignora tavole da disegno (tutta la grafica come una sola pagina)
|
||||
Ignore Color Ignora colore
|
||||
Ignore White Ignora bianco
|
||||
Palette: Palette:
|
||||
Limited Limitata
|
||||
@@ -3236,6 +3238,7 @@
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}: fai clic per correggere con il colore più vicino ({hex})
|
||||
“{file}” is protected by a password. “{file}” è protetto da password.
|
||||
● Recording ● Registrazione in corso
|
||||
imageTrace Outline Contorno
|
||||
@plural {n} item|{n} items {n} elemento|{n} elementi
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Tabella colori ({n} colore)|Tabella colori ({n} colori)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Eliminare {n} stile grafico?|Eliminare questi {n} stili grafici?
|
||||
@@ -4251,3 +4254,5 @@ lighting Light Luce
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parametro sconosciuto `{key}`; forse intendevate `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parametro sconosciuto `{key}`; il comando non ha parametri
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys}
|
||||
Load EPS into Clipboard Carica EPS negli appunti
|
||||
@msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -666,6 +666,7 @@
|
||||
Hide Print Tiling プリント分割を隠す
|
||||
Unlock Grid グリッドをロック解除
|
||||
Edit Envelope エンベロープを編集
|
||||
Auto Color 自動カラー
|
||||
Automatic 自動
|
||||
Isolate Selected Group 選択したグループを分離
|
||||
Exit Isolation Mode 分離モードを終了
|
||||
@@ -1481,6 +1482,7 @@
|
||||
Identify Objects By オブジェクトの識別方法
|
||||
Ignore 無視
|
||||
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
|
||||
Ignore Color カラーを無視
|
||||
Ignore White 白を無視
|
||||
Palette: パレット:
|
||||
Limited 限定
|
||||
@@ -3225,6 +3227,7 @@
|
||||
Yellow イエロー
|
||||
[Normal Character Style] [標準文字スタイル]
|
||||
[Normal Paragraph Style] [標準段落スタイル]
|
||||
imageTrace Outline アウトライン
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) カラーテーブル ({n} 色)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? {n} 個のグラフィックスタイルを削除しますか?
|
||||
@plural {n} Layer|{n} Layers {n} レイヤー
|
||||
@@ -4245,3 +4248,5 @@ lighting Light ライト
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? `{key}` は不明なパラメーターです。`{near}` のことですか?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters `{key}` は不明なパラメーターです。このコマンドにパラメーターはありません
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys}
|
||||
Load EPS into Clipboard EPS をクリップボードに読み込む
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -664,6 +664,7 @@
|
||||
Hide Print Tiling 인쇄 타일링 숨기기
|
||||
Unlock Grid 격자 잠금 해제
|
||||
Edit Envelope 엔벨로프 편집
|
||||
Auto Color 자동 색상
|
||||
Automatic 자동
|
||||
Isolate Selected Group 선택한 그룹 격리
|
||||
Exit Isolation Mode 격리 모드 종료
|
||||
@@ -1476,6 +1477,7 @@
|
||||
Identify Objects By 개체 식별 기준
|
||||
Ignore 무시
|
||||
Ignore Artboards (all the art as one page) 아트보드 무시 (모든 아트웍을 한 페이지로)
|
||||
Ignore Color 색상 무시
|
||||
Ignore White 흰색 무시
|
||||
Palette: 팔레트:
|
||||
Limited 제한
|
||||
@@ -3218,6 +3220,7 @@
|
||||
Yellow 노랑
|
||||
[Normal Character Style] [기본 문자 스타일]
|
||||
[Normal Paragraph Style] [기본 단락 스타일]
|
||||
imageTrace Outline 윤곽선
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) 색상 표 ({n}색)|색상 표 ({n}색)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? 그래픽 스타일 {n}개를 삭제하시겠습니까?|그래픽 스타일 {n}개를 삭제하시겠습니까?
|
||||
@plural {n} Layer|{n} Layers 레이어 {n}개|레이어 {n}개
|
||||
@@ -4215,3 +4218,5 @@ lighting Light 광원
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 알 수 없는 매개변수 `{key}`: `{near}`을(를) 의미하셨습니까?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 알 수 없는 매개변수 `{key}`: 이 명령에는 매개변수가 없습니다
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys}
|
||||
Load EPS into Clipboard EPS를 클립보드로 불러오기
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -81,8 +81,20 @@ fn plural_east_slavic(n: u64) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Polish: only 1 is `one`; 2–4 (but not 12–14) `few`; everything else (0, 5–21, 25…) `many`
|
||||
/// (unlike Russian and Ukrainian, 21, 31 … take `many`: "21 warstw").
|
||||
fn plural_polish(n: u64) -> usize {
|
||||
if n == 1 {
|
||||
return 0;
|
||||
}
|
||||
match (n % 10, n % 100) {
|
||||
(2..=4, r) if !(12..=14).contains(&r) => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 14] = [
|
||||
pub static LANGUAGES: [LangInfo; 16] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
|
||||
// messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
|
||||
@@ -91,6 +103,11 @@ pub static LANGUAGES: [LangInfo; 14] = [
|
||||
// English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an
|
||||
// installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here.
|
||||
LangInfo { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Vietnamese: complete menus and messages, with one plural form.
|
||||
// Every `vi-*` locale resolves here.
|
||||
LangInfo {
|
||||
code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()
|
||||
},
|
||||
// Czech: the whole interface (every menu string and `tl!` literal) and the status and error
|
||||
// messages, with one/few/other plurals, keeping the same names in English as Spanish.
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -151,6 +168,10 @@ pub static LANGUAGES: [LangInfo; 14] = [
|
||||
// English as Spanish; a count takes the singular ("3 réteg"), so plurals have one form. Every
|
||||
// `hu-*` locale (including `hu-HU`) resolves here.
|
||||
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Polish: the whole interface and the status and error messages, with one/few/many plurals,
|
||||
// keeping the same names in English as Spanish; every `pl-*` locale (including `pl-PL`)
|
||||
// resolves here.
|
||||
LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Brazilian Portuguese: every menu label (`menu_catalogs_translate_every_menu_label`), every
|
||||
// `tl!` literal and the plural messages; left English on purpose are the `MENU_KEEP_AS_IS`
|
||||
// strings (product name, language names, workspace names, perspective grid presets).
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1070,7 +1070,7 @@
|
||||
Identify Objects By Identificar Objetos Por
|
||||
Ignore Ignorar
|
||||
Ignore Artboards (all the art as one page) Ignorar Mesas de Trabalho (toda a arte como uma página)
|
||||
Ignore White Ignorar Branco
|
||||
Ignore Color Ignorar Cor
|
||||
Image Info Informações da Imagem
|
||||
Image Map: Mapa da Imagem:
|
||||
Image Size Tamanho da Imagem
|
||||
@@ -2661,6 +2661,7 @@
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}: clique para corrigir para a cor mais próxima ({hex})
|
||||
“{file}” is protected by a password. “{file}” é protegido por uma senha.
|
||||
● Recording ● Gravando
|
||||
imageTrace Outline Contorno
|
||||
@plural {n} item|{n} items {n} item|{n} itens
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Tabela de Cores ({n} cor)|Tabela de Cores ({n} cores)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Excluir este {n} estilo gráfico?|Excluir estes {n} estilos gráficos?
|
||||
@@ -3692,3 +3693,4 @@ axis Both Ambos
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parâmetro desconhecido `{key}`; você quis dizer `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parâmetro desconhecido `{key}`; o comando não tem parâmetros
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parâmetro desconhecido `{key}`; o comando aceita: {keys}
|
||||
Load EPS into Clipboard Carregar EPS na Área de Transferência
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -245,6 +245,7 @@
|
||||
Author Title: Должность автора:
|
||||
Author: Автор:
|
||||
Auto Авто
|
||||
Auto Color Автоцвет
|
||||
Automatic Автоматически
|
||||
Auto Size New Area Type Автоматически подгонять размер нового текста в области
|
||||
Auto Spell Check Автоматическая проверка орфографии
|
||||
@@ -1241,6 +1242,7 @@
|
||||
Identify Objects By Определять объекты по
|
||||
Ignore Игнорировать
|
||||
Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница)
|
||||
Ignore Color Игнорировать цвет
|
||||
Ignore White Игнорировать белый
|
||||
Palette: Палитра:
|
||||
Limited Ограниченная
|
||||
@@ -3232,6 +3234,7 @@
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}: щёлкните для исправления на ближайший цвет ({hex})
|
||||
“{file}” is protected by a password. «{file}» защищён паролем.
|
||||
● Recording ● Запись
|
||||
imageTrace Outline Контур
|
||||
@plural {n} item|{n} items {n} элемент|{n} элемента|{n} элементов
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Цветовая таблица ({n} цвет)|Цветовая таблица ({n} цвета)|Цветовая таблица ({n} цветов)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Удалить этот графический стиль ({n})?|Удалить эти графические стили ({n})?|Удалить эти графические стили ({n})?
|
||||
@@ -4247,3 +4250,5 @@ lighting Light Свет
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? неизвестный параметр `{key}`; возможно, имелся в виду `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters неизвестный параметр `{key}`; у команды нет параметров
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys}
|
||||
Load EPS into Clipboard Загрузить EPS в буфер обмена
|
||||
@msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -32,6 +32,9 @@ fn tags_map_to_languages() {
|
||||
for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(ko()), "{tag}");
|
||||
}
|
||||
for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(vi()), "{tag}");
|
||||
}
|
||||
// Traditional Chinese: by region, by script, and with a region after the script.
|
||||
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
|
||||
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
|
||||
@@ -449,7 +452,7 @@ fn cs() -> Lang {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
|
||||
const KEEPS_MENU_NAMES: [&str; 11] = ["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pt-br", "ru", "uk"];
|
||||
const KEEPS_MENU_NAMES: [&str; 13] = ["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pl", "pt-br", "ru", "uk", "vi"];
|
||||
|
||||
/// Menu labels the menu-complete catalogs (Czech, German, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
|
||||
@@ -662,6 +665,63 @@ fn korean_reads_as_korean() {
|
||||
assert_eq!(message(ko(), nested), "명령 `object.group`은(는) 지금 사용할 수 없습니다: 선택된 항목이 없습니다");
|
||||
}
|
||||
|
||||
fn vi() -> Lang {
|
||||
Lang::from_code("vi").expect("vi registered")
|
||||
}
|
||||
|
||||
/// Vietnamese has one plural form and covers the complete interface and messages.
|
||||
#[test]
|
||||
fn vietnamese_reads_as_vietnamese() {
|
||||
assert_eq!(vi().name(), "Tiếng Việt");
|
||||
assert!(vi().complete_menus());
|
||||
assert_eq!(Lang::from_pref("vi"), vi());
|
||||
assert_eq!(Lang::from_pref("VI"), vi());
|
||||
for (en, want) in [
|
||||
("Artboard Tool", "Công cụ Bảng vẽ"),
|
||||
("Swatches", "Mẫu màu"),
|
||||
("Stroke", "Nét"),
|
||||
("Fill", "Tô"),
|
||||
("Direct Selection Tool", "Công cụ Chọn trực tiếp"),
|
||||
("Save As…", "Lưu thành…"),
|
||||
("Undo", "Hoàn tác"),
|
||||
("Close", "Đóng"),
|
||||
] {
|
||||
assert_eq!(tr(vi(), en), want);
|
||||
}
|
||||
for n in [0, 1, 3, 21] {
|
||||
assert_eq!(trn(vi(), n, "{n} Layer", "{n} Layers"), format!("{n} lớp"));
|
||||
}
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
assert_eq!(message(vi(), nested), "lệnh `object.group` hiện không khả dụng: chưa chọn gì");
|
||||
}
|
||||
|
||||
/// Ambiguous English terms keep their intended Vietnamese meaning across UI contexts.
|
||||
#[test]
|
||||
fn vietnamese_domain_terms_are_consistent() {
|
||||
for (en, want) in [
|
||||
("All Scripts", "Tất cả hệ chữ"),
|
||||
("Pointillize", "Hiệu ứng chấm"),
|
||||
("Object Highlighting", "Đánh dấu đối tượng"),
|
||||
("Color Theme", "Bộ màu"),
|
||||
("Color Themes", "Bộ màu"),
|
||||
("Asset Export", "Xuất asset"),
|
||||
("Envelope Distort", "Biến dạng Envelope"),
|
||||
("Move Spine Point", "Di chuyển điểm trục"),
|
||||
("Dashed Line", "Đường nét đứt"),
|
||||
("Eyedropper Tool", "Công cụ Ống hút màu"),
|
||||
("Character Alignment", "Căn chỉnh ký tự"),
|
||||
] {
|
||||
assert_eq!(tr(vi(), en), want, "{en}");
|
||||
}
|
||||
assert_eq!(tr(vi(), "Appearance"), "Diện mạo");
|
||||
assert_eq!(tr_ctx(vi(), "theme", "Appearance"), "Giao diện");
|
||||
assert_eq!(message(vi(), "no saved themes"), "chưa có bộ màu nào được lưu");
|
||||
assert_eq!(message(vi(), r#"choice must be "merge" or "add", not `foo`"#), r#"lựa chọn phải là "merge" hoặc "add", không phải `foo`"#);
|
||||
assert_eq!(message(vi(), "select a blend"), "hãy chọn một đối tượng hòa trộn");
|
||||
assert_eq!(message(vi(), "select a repeat"), "hãy chọn một đối tượng lặp");
|
||||
assert_eq!(message(vi(), "Replace Spine: select a blend and a path"), "Thay thế trục: chọn một đối tượng hòa trộn và một đường dẫn");
|
||||
}
|
||||
|
||||
fn uk() -> Lang {
|
||||
Lang::from_code("uk").expect("uk registered")
|
||||
}
|
||||
@@ -921,7 +981,7 @@ fn russian_plurals_have_three_forms() {
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]);
|
||||
}
|
||||
|
||||
/// Czech, German, Spanish, French, Italian and Hungarian letters (and the punctuation their text uses) come from each family's own first font, not
|
||||
/// Czech, German, Spanish, French, Italian, Hungarian and Polish letters (and the punctuation their text uses) come from each family's own first font, not
|
||||
/// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the
|
||||
/// face that draws missing glyphs, the first one, as missing.)
|
||||
#[test]
|
||||
@@ -936,7 +996,7 @@ fn latin_script_glyphs_are_available_without_system_fonts() {
|
||||
let first = first.unwrap();
|
||||
let mut font = fonts.fonts.font(&family);
|
||||
let chars = font.characters();
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖßőűŐŰ\u{a0}".chars()
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖßőűŐŰąćęłńśźżĄĆĘŁŃŚŹŻ\u{a0}".chars()
|
||||
{
|
||||
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
|
||||
}
|
||||
@@ -1103,7 +1163,7 @@ fn select_key_param_error_is_translated() {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "ru", "uk"];
|
||||
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pl", "ru", "uk", "vi"];
|
||||
|
||||
/// Crates whose error and status messages reach the status bar.
|
||||
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
|
||||
@@ -1235,3 +1295,56 @@ fn czech_render_site_follow_up() {
|
||||
assert_eq!(trn(cs(), n, "{n} PR", "{n} PRs"), format!("{n} PR"));
|
||||
}
|
||||
}
|
||||
|
||||
fn pl() -> Lang {
|
||||
Lang::from_code("pl").expect("pl registered")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_plurals_have_three_forms() {
|
||||
let forms: Vec<usize> = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 112, 122].into_iter().map(plural_polish).collect();
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 1, 2, 2, 2, 1]);
|
||||
// Every Polish plural row has the three forms, and each count picks its own.
|
||||
let (entries, _) = parse_entries(pl().0.source);
|
||||
let mut count = 0;
|
||||
for (ctx, src, tr) in &entries {
|
||||
if ctx != "@plural" {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let forms: Vec<&str> = tr.split('|').collect();
|
||||
assert_eq!(forms.len(), 3, "{src}");
|
||||
let (one, other) = src.split_once('|').unwrap();
|
||||
for (n, form) in [(1, 0), (3, 1), (5, 2), (21, 2), (22, 1)] {
|
||||
assert_eq!(trn(pl(), n, one, other), forms[form].replace("{n}", &n.to_string()), "{src}: {n}");
|
||||
}
|
||||
}
|
||||
assert!(count >= 13, "Polish plural rows missing");
|
||||
}
|
||||
|
||||
/// Polish uses its vector-illustration vocabulary (obszar kompozycji, ścieżka, obrys…), resolves
|
||||
/// every `pl-*` locale, counts with one/few/many and translates the reason inside a message too.
|
||||
#[test]
|
||||
fn polish_reads_as_polish() {
|
||||
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(pl()), "{tag}");
|
||||
}
|
||||
for (en, want) in [
|
||||
("File", "Plik"),
|
||||
("Artboard Tool", "Narzędzie Obszar kompozycji"),
|
||||
("Swatches", "Próbki"),
|
||||
("Pathfinder", "Odnajdywanie ścieżek"),
|
||||
("Stroke", "Obrys"),
|
||||
("Fill", "Wypełnienie"),
|
||||
("Workspace", "Obszar roboczy"),
|
||||
] {
|
||||
assert_eq!(tr(pl(), en), want);
|
||||
}
|
||||
for (n, word) in [(1, "warstwa"), (3, "warstwy"), (5, "warstw"), (22, "warstwy"), (21, "warstw")] {
|
||||
assert_eq!(trn(pl(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}"));
|
||||
}
|
||||
assert_eq!(tr_ctx(pl(), "axis", "Both"), "Obie");
|
||||
assert_eq!(tr_id(pl(), "pathfinder.add", "Add"), "Połącz");
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
assert_eq!(message(pl(), nested), "polecenie `object.group` jest teraz niedostępne: nic nie zaznaczono");
|
||||
}
|
||||
@@ -241,6 +241,7 @@
|
||||
Author Title: Посада автора:
|
||||
Author: Автор:
|
||||
Auto Авто
|
||||
Auto Color Автоколір
|
||||
Automatic Автоматично
|
||||
Auto Size New Area Type Автоматично підганяти розмір нового тексту в області
|
||||
Auto Spell Check Автоматична перевірка орфографії
|
||||
@@ -1237,6 +1238,7 @@
|
||||
Identify Objects By Визначати об’єкти по
|
||||
Ignore Ігнорувати
|
||||
Ignore Artboards (all the art as one page) Ігнорувати монтажні області (весь малюнок як одна сторінка)
|
||||
Ignore Color Ігнорувати колір
|
||||
Ignore White Ігнорувати білий
|
||||
Palette: Палітра:
|
||||
Limited Обмежена
|
||||
@@ -3229,6 +3231,7 @@ effect Points Точки
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}: натисніть , щоб виправити найближчий колір ({hex})
|
||||
“{file}” is protected by a password. "{file}" захищений паролем.
|
||||
● Recording ● Запис
|
||||
imageTrace Outline Контур
|
||||
@plural {n} item|{n} items {n} елемент|{n} елементи|{n} елементів
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Таблиця кольорів ({n} колір)|Таблиця кольорів ({n} кольори)|Таблиця кольорів ({n} кольорів)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Видалити цей графічний стиль ({n})?|Видалити ці графічні стилі ({n})?|Видалити ці графічні стилі ({n})?
|
||||
@@ -4231,3 +4234,5 @@ lighting Light Світло
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? невідомий параметр `{key}`; можливо, ви мали на увазі `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters невідомий параметр `{key}`; команда не має параметрів
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys}
|
||||
Load EPS into Clipboard Завантажити EPS у буфер обміну
|
||||
@msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення
|
||||
|
Can't render this file because it contains an unexpected character in line 660 and column 40.
|
File diff suppressed because it is too large.
Load diff
@@ -205,6 +205,7 @@
|
||||
Author Title: 作者职务:
|
||||
Author: 作者:
|
||||
Auto 自动
|
||||
Auto Color 自动颜色
|
||||
Automatic 自动
|
||||
Auto Size New Area Type 自动调整新区域文字大小
|
||||
Auto Spell Check 自动拼写检查
|
||||
@@ -1189,6 +1190,7 @@
|
||||
Identify Objects By 对象识别方式
|
||||
Ignore 忽略
|
||||
Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页)
|
||||
Ignore Color 忽略颜色
|
||||
Ignore White 忽略白色
|
||||
Palette: 调板:
|
||||
Limited 有限
|
||||
@@ -3129,6 +3131,7 @@
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}:单击以修正为最接近的颜色 ({hex})
|
||||
“{file}” is protected by a password. “{file}”受密码保护。
|
||||
● Recording ● 录制中
|
||||
imageTrace Outline 轮廓
|
||||
@plural {n} item|{n} items {n} 个项目
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) 颜色表 ({n} 色)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? 要删除这 {n} 个图形样式吗?
|
||||
@@ -3842,3 +3845,5 @@ lighting Light 光源
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 未知参数 `{key}`;您是否想输入 `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 未知参数 `{key}`;该命令没有参数
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys}
|
||||
Load EPS into Clipboard 将 EPS 载入剪贴板
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -216,6 +216,7 @@
|
||||
Author Title: 作者職稱:
|
||||
Author: 作者:
|
||||
Auto 自動
|
||||
Auto Color 自動顏色
|
||||
Automatic 自動
|
||||
Auto Size New Area Type 自動調整新區域文字大小
|
||||
Auto Spell Check 自動拼字檢查
|
||||
@@ -1219,6 +1220,7 @@
|
||||
Identify Objects By 識別物件依據
|
||||
Ignore 略過
|
||||
Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面)
|
||||
Ignore Color 忽略顏色
|
||||
Ignore White 忽略白色
|
||||
Palette: 調色盤:
|
||||
Limited 有限
|
||||
@@ -3222,6 +3224,7 @@
|
||||
{warning}: click to correct to the closest color ({hex}) {warning}:按一下以修正為最接近的顏色 ({hex})
|
||||
“{file}” is protected by a password. 「{file}」受密碼保護。
|
||||
● Recording ● 錄製中
|
||||
imageTrace Outline 輪廓
|
||||
@plural {n} item|{n} items {n} 個項目
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) 色彩表 ({n} 色)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? 要刪除這 {n} 個繪圖樣式嗎?
|
||||
@@ -3623,3 +3626,5 @@ lighting Light 光源
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 未知的參數 `{key}`;您是指 `{near}` 嗎?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 未知的參數 `{key}`;此指令沒有參數
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys}
|
||||
Load EPS into Clipboard 將 EPS 載入剪貼簿
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -560,7 +560,9 @@ pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
|
||||
/// Bring the native menu up to date when anything it shows may have changed: a command ran, the
|
||||
/// document, its history or selection changed, the user clicked or pressed a key. Reading the rows
|
||||
/// asks every command whether it is enabled, so frames that only animate or scroll skip it.
|
||||
/// asks every command whether it is enabled, so frames that only animate or scroll skip it, and so
|
||||
/// do the previews of an open interaction (the steps of a move, scale or shape drag, a dialog's
|
||||
/// preview), whose new revisions [`state_hash`] leaves out.
|
||||
pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(menu) = app.services.native_menu.as_ref() else { return };
|
||||
let input = ctx
|
||||
@@ -585,7 +587,10 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
/// What the menus' rows depend on, cheaply (no allocation): commands run, the active document,
|
||||
/// its revision (edits, selection and view changes), history and clipboard, the language,
|
||||
/// shortcuts, plug-ins and an open file dialog. `input` forces a change on frames with a click or
|
||||
/// a key press, which covers what the hash doesn't list (panels shown, a tool picked).
|
||||
/// a key press, which covers what the hash doesn't list (panels shown, a tool picked). While an
|
||||
/// interaction is open (for example a move, scale or shape drag, a dialog's or the font menu's
|
||||
/// preview, or a typing session), the revision, which each of its previews changes, is left out:
|
||||
/// the hash changes as the interaction begins and as it ends.
|
||||
fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
input.hash(&mut h);
|
||||
@@ -593,7 +598,8 @@ fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
|
||||
let s = &app.session;
|
||||
(s.journal.len(), s.active_index(), s.clipboard.is_empty(), s.tool_composing()).hash(&mut h);
|
||||
if let Some(d) = s.active() {
|
||||
(d.uid, d.revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h);
|
||||
let revision = d.interaction.is_none().then_some(d.revision);
|
||||
(d.uid, revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h);
|
||||
}
|
||||
(app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h);
|
||||
app.file_dialog_open().hash(&mut h);
|
||||
|
||||
@@ -19,6 +19,16 @@ const MODES: [(&str, &str); 4] = [("blackAndWhite", "Black and White"), ("graysc
|
||||
const PALETTES: [&str; 4] = ["limited", "fullTone", "automatic", "documentLibrary"];
|
||||
|
||||
/// Palette `id`, in the UI language.
|
||||
/// The colour Ignore Color leaves out in `params` (`ignoreColor`, `#rrggbb`): white when there is none.
|
||||
fn ignore_color(params: &Value) -> [u8; 3] {
|
||||
let hex = params["ignoreColor"].as_str().unwrap_or("").trim_start_matches('#');
|
||||
let channel = |i: usize| hex.get(i * 2..i * 2 + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
match (hex.len(), channel(0), channel(1), channel(2)) {
|
||||
(6, Some(r), Some(g), Some(b)) => [r, g, b],
|
||||
_ => [255; 3],
|
||||
}
|
||||
}
|
||||
|
||||
fn palette_label(id: &str) -> &'static str {
|
||||
match id {
|
||||
"fullTone" => tl!("Full Tone"),
|
||||
@@ -128,10 +138,16 @@ fn trace_with(app: &mut VectorcraftApp, preset: &str) {
|
||||
|
||||
/// The built-in presets to choose from (`current` highlighted) → the one chosen. Listed only
|
||||
/// while the menu is open.
|
||||
/// A preset's name as shown: translated as a preset's name where the catalog has it so (the
|
||||
/// Outline preset is not the Outline view), as any other string else.
|
||||
fn preset_label(name: &str) -> &str {
|
||||
crate::i18n::tr_ctx(crate::i18n::current(), "imageTrace", name)
|
||||
}
|
||||
|
||||
fn preset_list(app: &mut VectorcraftApp, ui: &mut Ui, current: &str) -> Option<String> {
|
||||
let mut chosen = None;
|
||||
for (name, _) in presets(app) {
|
||||
if ui.add(egui::Button::selectable(name == current, tl!(&name))).clicked() {
|
||||
if ui.add(egui::Button::selectable(name == current, preset_label(&name))).clicked() {
|
||||
chosen = Some(name);
|
||||
}
|
||||
}
|
||||
@@ -338,14 +354,29 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
retrace |= release;
|
||||
}
|
||||
for (key, label) in [("snapCurvesToLines", tl!("Snap Curves To Lines")), ("ignoreWhite", tl!("Ignore White"))] {
|
||||
let on = st.params[key].as_bool().unwrap_or(false);
|
||||
if widgets::check(ui, label, on, true) {
|
||||
st.params[key] = json!(!on);
|
||||
let snap = st.params["snapCurvesToLines"].as_bool().unwrap_or(false);
|
||||
if widgets::check(ui, tl!("Snap Curves To Lines"), snap, true) {
|
||||
st.params["snapCurvesToLines"] = json!(!snap);
|
||||
st.preset = "Custom".into();
|
||||
retrace = true;
|
||||
}
|
||||
// Ignore Color: white, unless another colour is picked.
|
||||
ui.horizontal(|ui| {
|
||||
let on = st.params["ignoreWhite"].as_bool().unwrap_or(false);
|
||||
if widgets::check(ui, tl!("Ignore Color"), on, true) {
|
||||
st.params["ignoreWhite"] = json!(!on);
|
||||
st.preset = "Custom".into();
|
||||
retrace = true;
|
||||
}
|
||||
}
|
||||
if on {
|
||||
let mut rgb = ignore_color(&st.params);
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
st.params["ignoreColor"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]));
|
||||
st.preset = "Custom".into();
|
||||
retrace = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
widgets::divider(ui);
|
||||
if let Some((p, a, c)) = st.info {
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user