Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
250da4226e | ||
|
|
8d2d838884 | ||
|
|
bc5bdf47d1 | ||
|
|
6b99150aac | ||
|
|
26bb452ba1 | ||
|
|
dbb8bdcaaa | ||
|
|
ecf4ae7a3a | ||
|
|
e518974913 | ||
|
|
a20b0308b8 | ||
|
|
3126e82ebd | ||
|
|
d8a298a310 | ||
|
|
9c42a2942a | ||
|
|
36286cc783 | ||
|
|
bc343414d8 | ||
|
|
7b0164e4e7 | ||
|
|
670946ef75 | ||
|
|
bdb9963c73 | ||
|
|
dc64aaa41b | ||
|
|
5366cf689e | ||
|
|
cca3a63e4a | ||
|
|
507c6e0e63 | ||
|
|
dfc1b846cc | ||
|
|
a169693817 | ||
|
|
db85d465c8 | ||
|
|
de8d5c96ab | ||
|
|
d44615753a | ||
|
|
e794c1ac63 | ||
|
|
0573d3cdfb | ||
|
|
9ca74ac011 | ||
|
|
f0f3cb4eb1 | ||
|
|
5fe916a425 | ||
|
|
8e7b9ba436 | ||
|
|
cbcee3bd54 | ||
|
|
0810a15f6e | ||
|
|
db7e568809 | ||
|
|
9d72686d95 | ||
|
|
d79fb145c2 | ||
|
|
6a16627de6 | ||
|
|
acc1c9a85d | ||
|
|
8fbf724cf6 | ||
|
|
02ed637c68 | ||
|
|
9d2b074d12 | ||
|
|
632ea2a9f3 | ||
|
|
523fd6e185 | ||
|
|
612082e26e | ||
|
|
fea316911d | ||
|
|
3af08505f8 | ||
|
|
2c01a42b35 | ||
|
|
45334df881 | ||
|
|
ee97225c19 | ||
|
|
6085197bf4 | ||
|
|
50911d8608 | ||
|
|
6939eff2f9 | ||
|
|
9652d1d6b2 | ||
|
|
79b5d73ca7 | ||
|
|
05fdd15b45 | ||
|
|
0a16b62a22 | ||
|
|
20f5174710 | ||
|
|
00195e8d28 | ||
|
|
05e321f4e0 | ||
|
|
85770d3afb | ||
|
|
421443b8ac | ||
|
|
e2a63a8baf | ||
|
|
44d170f059 | ||
|
|
fce90e3144 | ||
|
|
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 |
No files matched your search
Generated
-1
@@ -4916,7 +4916,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -220,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, Vietnamese, 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.
|
||||
|
||||
+3
-2
@@ -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 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; 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
|
||||
@@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
|
||||
| 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. |
|
||||
@@ -137,7 +138,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 |
|
||||
|
||||
@@ -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
|
||||
@@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
|
||||
|
||||
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
|
||||
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?);
|
||||
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
|
||||
if let Some(date) = bytes.get_mut(24..36) {
|
||||
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
|
||||
@@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1
|
||||
/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584).
|
||||
/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is.
|
||||
fn aligned(icc: Vec<u8>) -> Vec<u8> {
|
||||
const TABLE: usize = 128;
|
||||
let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
|
||||
let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc };
|
||||
let data_start = TABLE + 4 + 12 * n;
|
||||
let mut entries = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let e = TABLE + 4 + 12 * i;
|
||||
let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc };
|
||||
if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) {
|
||||
return icc;
|
||||
}
|
||||
entries.push((off, size));
|
||||
}
|
||||
if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) {
|
||||
return icc;
|
||||
}
|
||||
let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
let mut moved: Vec<((usize, usize), usize)> = Vec::new();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by_key(|&i| entries[i].0);
|
||||
for i in order {
|
||||
let (off, size) = entries[i];
|
||||
let at = match moved.iter().find(|(k, _)| *k == (off, size)) {
|
||||
Some((_, at)) => *at,
|
||||
None => {
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let at = out.len();
|
||||
out.extend_from_slice(&icc[off..off + size]);
|
||||
moved.push(((off, size), at));
|
||||
at
|
||||
}
|
||||
};
|
||||
let e = TABLE + 4 + 12 * i + 4;
|
||||
out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes());
|
||||
}
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let len = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&len.to_be_bytes());
|
||||
// The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed".
|
||||
if let Some(id) = out.get_mut(84..100) {
|
||||
id.fill(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text_tag(s: &str) -> Option<ProfileText> {
|
||||
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
|
||||
}
|
||||
|
||||
@@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
|
||||
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
|
||||
assert_eq!(abs(true), abs(false));
|
||||
}
|
||||
|
||||
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
|
||||
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
|
||||
#[test]
|
||||
fn written_profiles_align_their_tags() {
|
||||
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
|
||||
let icc = super::icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
assert_eq!(be(0), icc.len(), "{name}: header size");
|
||||
assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len());
|
||||
let n = be(128);
|
||||
for i in 0..n {
|
||||
let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8));
|
||||
assert_eq!(off % 4, 0, "{name}: tag {i} at {off}");
|
||||
assert!(off + size <= icc.len());
|
||||
}
|
||||
let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap();
|
||||
let orig = moxcms::ColorProfile::new_srgb();
|
||||
if name == super::SRGB {
|
||||
assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant));
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
-16
@@ -1,5 +1,7 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
@@ -83,6 +85,77 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
|
||||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
|
||||
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
|
||||
// while a group (including a clipping group) is selected as one object. Repeatedly
|
||||
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
|
||||
// Isolation and Edit Contents change where selection groups begin; keep the original
|
||||
// traversal for those cases rather than change their Select Behind semantics.
|
||||
if opt.scope.is_some() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
let mut tops = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for layer in doc.layers.iter().rev() {
|
||||
// Malformed files may contain non-layer roots; let the general traversal handle them.
|
||||
if !layer.is_layer() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
if !objects_at_layer(layer, p, opt, &mut tops, &mut seen) {
|
||||
// An envelope in Edit Contents may expose objects *within* a top-level group.
|
||||
// The repeated traversal handles that mixed hierarchy precisely.
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
}
|
||||
tops
|
||||
}
|
||||
|
||||
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
|
||||
/// inspected once (only until its first hit). `false` asks the caller to use the general
|
||||
/// algorithm for an envelope whose contents are currently being edited.
|
||||
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
|
||||
let Some(children) = layer.children() else { return true };
|
||||
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return true;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if !may_hit(layer, i, c, p, opt, true) || seen.contains(&c.id) {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
if !objects_at_layer(c, p, opt, tops, seen) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Editing an envelope selects its contents rather than the containing object. Even
|
||||
// when nested in a group, the old traversal may select both a member and the group.
|
||||
if c.is_container() {
|
||||
let mut editing = false;
|
||||
c.walk(&mut |n| editing |= edits_contents(n));
|
||||
if editing {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let skip = |id| seen.contains(&id);
|
||||
let mut chain = Vec::new();
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Group { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some(),
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt).is_some(),
|
||||
_ => hit_leaf(c, p, opt).is_some(),
|
||||
};
|
||||
if hit && seen.insert(c.id) {
|
||||
tops.push(c.id);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The general selection order also handles isolation and nested Edit Contents.
|
||||
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let mut tops: Vec<NodeId> = vec![];
|
||||
// Each round leaves out the objects found so far (and everything inside them).
|
||||
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
|
||||
@@ -150,6 +223,22 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
|
||||
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
|
||||
/// isn't shown, or when `p` is outside its reach.
|
||||
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, editable_only: bool) -> bool {
|
||||
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
|
||||
return false;
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c) || c.extras().shaper.is_some() && source_isolated(c) || c.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
@@ -167,22 +256,7 @@ fn hit_children(
|
||||
return None;
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if parent.extras().shaper.is_some() {
|
||||
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if (i == 0) != construction {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !(edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
if !may_hit(parent, i, c, p, opt, editable_only) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
chain.push(c.id);
|
||||
@@ -619,6 +693,146 @@ world",
|
||||
assert!(at(90.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
|
||||
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
|
||||
/// the per-object walk.
|
||||
#[test]
|
||||
fn objects_at_large_overlapping_stack() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..256 {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
ids.reverse();
|
||||
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
|
||||
}
|
||||
|
||||
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
|
||||
/// invisibility, and path/outline-only settings.
|
||||
#[test]
|
||||
fn objects_at_one_pass_matches_repeated_hits() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let layer = d.layers[0].id;
|
||||
let add = |d: &mut Document, parent, bounds| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
|
||||
id
|
||||
};
|
||||
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
|
||||
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let group = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
|
||||
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
|
||||
let clip = d.alloc_id();
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
|
||||
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
|
||||
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
|
||||
clipping.set_clips(true);
|
||||
d.insert(Some(layer), usize::MAX, clipping).unwrap();
|
||||
for (point, opt) in [
|
||||
(Point::new(25.0, 25.0), HitOptions::default()),
|
||||
(Point::new(3.0, 25.0), HitOptions::default()),
|
||||
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
|
||||
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
|
||||
(Point::new(100.0, 100.0), HitOptions::default()),
|
||||
] {
|
||||
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
|
||||
}
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
d.node_mut(group).unwrap().locked = true;
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
}
|
||||
|
||||
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
|
||||
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
|
||||
/// in the same order as the hit test repeated over what is left.
|
||||
#[test]
|
||||
fn objects_at_matches_repeated_hits_on_random_documents() {
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
let mut next = move |n: u32| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed % n
|
||||
};
|
||||
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
|
||||
for _ in 0..2 + next(4) {
|
||||
let id = d.alloc_id();
|
||||
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
|
||||
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
|
||||
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
|
||||
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
|
||||
4 | 5 => Node::group(id, Vec::new()),
|
||||
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
|
||||
7 => {
|
||||
let mut g = Node::group(id, Vec::new());
|
||||
g.set_clips(true);
|
||||
g
|
||||
}
|
||||
_ => Node::path(id, shapes::rectangle(rect), appearance),
|
||||
};
|
||||
n.visible = next(8) != 0;
|
||||
n.locked = next(8) == 0;
|
||||
if let NodeKind::Layer { preview, .. } = &mut n.kind {
|
||||
*preview = next(3) != 0;
|
||||
}
|
||||
let container = n.is_container();
|
||||
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
if container {
|
||||
fill(d, id, depth + 1, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
for round in 0..200 {
|
||||
let mut d = Document::new(120.0, 120.0);
|
||||
let layer = d.layers[0].id;
|
||||
fill(&mut d, layer, 0, &mut next);
|
||||
for _ in 0..12 {
|
||||
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
|
||||
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Contents can expose descendants of a top-level group: preserve the original
|
||||
/// selection order by falling back to the general hit traversal.
|
||||
#[test]
|
||||
fn objects_at_edit_contents_uses_general_order() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
|
||||
let (a, b) = (d.alloc_id(), d.alloc_id());
|
||||
let e = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Envelope {
|
||||
content: vec![inner(a), inner(b)],
|
||||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||||
fidelity: 100.0,
|
||||
editing: true,
|
||||
options: Default::default(),
|
||||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
d.insert(Some(layer), usize::MAX, e).unwrap();
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_skip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -1465,6 +1465,14 @@ impl Document {
|
||||
pub fn new_graphic_style_name(&self) -> String {
|
||||
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
|
||||
}
|
||||
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
|
||||
pub fn unique_symbol_name(&self, base: &str) -> String {
|
||||
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
|
||||
+39
-8
@@ -318,8 +318,10 @@ impl WarpStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
|
||||
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
|
||||
/// proportions).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
use WarpStyle::*;
|
||||
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
|
||||
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
|
||||
@@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) ->
|
||||
if b.abs() < 1e-6 {
|
||||
(x, y)
|
||||
} else {
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
|
||||
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
|
||||
// the edges are concentric arcs, and one that would pass the centre stops there
|
||||
// instead of folding over (#1578).
|
||||
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
|
||||
let sweep = b.abs() * PI;
|
||||
let r0 = 2.0 / sweep;
|
||||
let yy = if b > 0.0 { y } else { -y };
|
||||
let yy = if b > 0.0 { y } else { -y } * k;
|
||||
let a = x / r0;
|
||||
let r = r0 - yy;
|
||||
let r = (r0 - yy).max(0.0);
|
||||
let (s, c) = a.sin_cos();
|
||||
let (nx, ny) = (r * s, r0 - r * c);
|
||||
let (nx, ny) = (r * s, (r0 - r * c) / k);
|
||||
(nx, if b > 0.0 { ny } else { -ny })
|
||||
}
|
||||
}
|
||||
@@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
|
||||
Box::new(move |u: f64, v: f64| {
|
||||
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
|
||||
let (x2, y2) = if horizontal {
|
||||
warp_point(st, b, dh, dv, x, y)
|
||||
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
|
||||
} else {
|
||||
let (a, b2) = warp_point(st, b, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
|
||||
(b2, a)
|
||||
};
|
||||
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
@@ -1711,6 +1717,31 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
#[test]
|
||||
fn arc_warp_is_round_in_real_proportions() {
|
||||
for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] {
|
||||
let r0 = 2.0 / (b * PI);
|
||||
for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] {
|
||||
for i in 0..=10 {
|
||||
let x = -1.0 + 0.2 * f64::from(i);
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y);
|
||||
let d = nx.hypot(ny * aspect - r0);
|
||||
assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// At 100 % on a square the inner edge meets at the centre instead of folding over.
|
||||
let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0);
|
||||
assert!(nx.abs() < 1e-12);
|
||||
// A square bent less than that is what it always was.
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25);
|
||||
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
|
||||
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
|
||||
}
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Distort & Transform effects.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::util::*;
|
||||
@@ -194,7 +195,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
map_nonlinear(path, pieces, f)
|
||||
}
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
|
||||
/// displaced along the normal.
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -207,8 +211,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let mut k = 0usize;
|
||||
for i in 0..segs {
|
||||
let c = sp.segment(i);
|
||||
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
|
||||
// near the anchors, and so does any uneven curve.
|
||||
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
|
||||
for j in 0..=ridges {
|
||||
let t = j as f64 / (ridges + 1) as f64;
|
||||
let s = j as f64 / (ridges + 1) as f64;
|
||||
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
|
||||
let nrm = if j == 0 {
|
||||
// Average the normals of the segments meeting at the anchor.
|
||||
let prev = if i > 0 {
|
||||
|
||||
@@ -244,6 +244,62 @@ fn zigzag_ridge_count_and_amplitude() {
|
||||
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
|
||||
// #1554: on a straight line every ridge is the same distance from the line and as far from
|
||||
// the next one, whether the points are corners or smooth.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false));
|
||||
for points in ["corner", "smooth"] {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line);
|
||||
let ps: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(ps.len(), 6, "{points}");
|
||||
for (k, q) in ps.iter().enumerate() {
|
||||
assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}");
|
||||
assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
|
||||
/// turn all the way round.
|
||||
#[test]
|
||||
fn zigzag_on_a_circle_is_regular() {
|
||||
let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0));
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle);
|
||||
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts.len(), 16);
|
||||
let start = pts[0].y.atan2(pts[0].x);
|
||||
let mut radii = vec![];
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5;
|
||||
assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths");
|
||||
radii.push(q.to_vec2().hypot().round());
|
||||
}
|
||||
assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}");
|
||||
}
|
||||
|
||||
/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts.
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
|
||||
],
|
||||
false,
|
||||
));
|
||||
let c = curve.subpaths[0].segment(0);
|
||||
let len = c.arclen(1e-6);
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve);
|
||||
let along: Vec<f64> = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect();
|
||||
assert_eq!(along.len(), 5);
|
||||
for (k, share) in along.iter().enumerate() {
|
||||
assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_grows_and_shrinks() {
|
||||
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
|
||||
|
||||
@@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
let f = |q: Point| {
|
||||
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
|
||||
let (x2, y2) = if vertical {
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
|
||||
(b2, a)
|
||||
} else {
|
||||
warp_point(style, bend, dh, dv, x, y)
|
||||
warp_point(style, bend, dh, dv, hh / hw, x, y)
|
||||
};
|
||||
Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
};
|
||||
|
||||
@@ -526,14 +526,6 @@ fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
Ok((art, (w, h), c))
|
||||
}
|
||||
|
||||
fn unique_symbol_name(d: &Document, base: &str) -> String {
|
||||
let taken = |n: &str| d.symbols.iter().any(|s| s.name == n);
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
@@ -587,7 +579,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
@@ -764,7 +756,7 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, C)?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
|
||||
let new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let size = natural_size(d, &name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -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, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), 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)
|
||||
),
|
||||
@@ -703,6 +703,76 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
|
||||
/// refused with advice instead (#525).
|
||||
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
|
||||
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
|
||||
use vectorcraft_geom::Affine;
|
||||
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
|
||||
let mut live = match shape {
|
||||
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
|
||||
w: width,
|
||||
h: height,
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
|
||||
},
|
||||
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
|
||||
}
|
||||
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
|
||||
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
|
||||
}
|
||||
};
|
||||
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
|
||||
let _ = live.transform(xf);
|
||||
live
|
||||
}
|
||||
|
||||
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
|
||||
/// document by `xf`.
|
||||
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
|
||||
/// inside it. Without parents, the nodes as they are.
|
||||
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
|
||||
let n = nodes.len();
|
||||
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut roots = vec![];
|
||||
for i in 0..n {
|
||||
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
|
||||
Some(of) => of.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
}
|
||||
// Outermost first, so that backwards each path comes after those inside it.
|
||||
let mut order = roots.clone();
|
||||
let mut at = 0;
|
||||
while let Some(&i) = order.get(at) {
|
||||
order.extend(inside.get(i).into_iter().flatten());
|
||||
at += 1;
|
||||
}
|
||||
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
|
||||
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
|
||||
for &i in order.iter().rev() {
|
||||
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
|
||||
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
|
||||
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
|
||||
if let Some(slot) = built.get_mut(i) {
|
||||
*slot = Some(Arc::new(made));
|
||||
}
|
||||
}
|
||||
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
|
||||
// and they are kept all the same.)
|
||||
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
|
||||
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
|
||||
}
|
||||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let mut params = trace_params(p)?;
|
||||
@@ -763,12 +833,20 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
hidden.visible = false;
|
||||
children.push(Arc::new(hidden));
|
||||
}
|
||||
let mut traced = Vec::with_capacity(res.paths.len());
|
||||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
// Shapes: a path the tracer found to be a shape is that live shape.
|
||||
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
|
||||
let shape = traced_shape(shape, xf);
|
||||
*path = shape.to_path();
|
||||
*live = Some(Box::new(shape));
|
||||
}
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
let paint = Paint::solid(color);
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round ends and corners, as drawn lines have.
|
||||
Some(width) => Appearance {
|
||||
@@ -782,8 +860,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
.into(),
|
||||
None => fill_only(paint).into(),
|
||||
};
|
||||
children.push(Arc::new(n));
|
||||
traced.push(n);
|
||||
}
|
||||
children.extend(grouped(d, traced, &res.parents));
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -489,8 +489,10 @@ fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
|
||||
s.menu.guides_locked = v;
|
||||
// Locked guides can't stay selected.
|
||||
if v && !s.doc()?.selection.guides.is_empty() {
|
||||
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
|
||||
// those go).
|
||||
let st = s.doc()?;
|
||||
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
|
||||
s.select(|_, sel| sel.guides.clear())?;
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
|
||||
@@ -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};
|
||||
@@ -1048,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;
|
||||
@@ -1079,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
|
||||
}
|
||||
|
||||
@@ -132,7 +132,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
|
||||
cmd!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
["Edit"],
|
||||
None,
|
||||
"{dx?, dy?} duplicate the selection in place (offset optional)",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -394,6 +395,17 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
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
|
||||
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
|
||||
|
||||
@@ -28,7 +28,7 @@ pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod load;
|
||||
pub(crate) use load::MAX_RASTER_ALLOC;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -150,6 +150,12 @@ fn accepted(doc: &'static str, depth: usize) -> Option<Vec<&'static str>> {
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
|
||||
/// its `…` entries name)?
|
||||
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
|
||||
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
|
||||
}
|
||||
|
||||
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
|
||||
fn distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::pathops::Beside;
|
||||
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Join",
|
||||
["Object", "Path"],
|
||||
Some("Cmd+J"),
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path",
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
|
||||
has_selection,
|
||||
join
|
||||
),
|
||||
@@ -300,6 +300,37 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
|
||||
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
|
||||
}
|
||||
|
||||
/// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which
|
||||
/// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many
|
||||
/// anchors went.
|
||||
fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
|
||||
let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6;
|
||||
let before = sp.anchors.len();
|
||||
// The closing pair of a closed path first: taking the last anchor moves no other index.
|
||||
if sp.closed
|
||||
&& before > 2
|
||||
&& picked(0)
|
||||
&& picked(before - 1)
|
||||
&& let [first, .., last] = sp.anchors.as_mut_slice()
|
||||
&& same(first, last)
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
sp.anchors.pop();
|
||||
}
|
||||
// Back to front, so the indices `picked` knows stay true for the anchors still to visit.
|
||||
for i in (1..sp.anchors.len()).rev() {
|
||||
if picked(i - 1)
|
||||
&& picked(i)
|
||||
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
|
||||
&& same(a, b)
|
||||
{
|
||||
a.h_out = b.h_out;
|
||||
sp.anchors.remove(i);
|
||||
}
|
||||
}
|
||||
before - sp.anchors.len()
|
||||
}
|
||||
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.join";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
@@ -312,6 +343,31 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => selected_paths(s)?,
|
||||
};
|
||||
// Direct-selected anchors lying on one another along a path (two corners brought together by
|
||||
// Average) become one anchor; the paths themselves are joined only when there are none.
|
||||
if p.get("ids").is_none() {
|
||||
let st = s.doc()?;
|
||||
let mut merged = vec![];
|
||||
for (id, picked) in &st.selection.anchors {
|
||||
let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
|
||||
let n: usize = path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)))).sum();
|
||||
if n > 0 {
|
||||
merged.push((*id, path));
|
||||
}
|
||||
}
|
||||
if !merged.is_empty() {
|
||||
let objects = st.selection.objects.clone();
|
||||
s.edit("Join", |d, sel| {
|
||||
for (id, path) in merged {
|
||||
*path_mut(d, id)? = path;
|
||||
}
|
||||
// The anchor indices changed: the paths stay selected, whole.
|
||||
sel.set(objects);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
// The ends to join, as (reverse the first path, reverse the second): the first path's end
|
||||
// must come last, the second's first.
|
||||
let ends = match p.get("ends") {
|
||||
|
||||
@@ -383,10 +383,15 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let mut styles: Vec<Style> = vec![];
|
||||
let mut shapes: Vec<po::Shape> = vec![];
|
||||
let mut names: Vec<Option<String>> = vec![];
|
||||
let mut push = |mut path: PathData, rule: FillRule, st: Style, name: Option<String>| {
|
||||
let mut push = |mut path: PathData, rule: FillRule, mut st: Style, name: Option<String>| {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Trim, Merge and Crop remove any strokes (and Merge then merges by fill alone), as the
|
||||
// public Pathfinder documentation describes.
|
||||
if matches!(op, PathfinderOp::Trim | PathfinderOp::Merge | PathfinderOp::Crop) {
|
||||
st.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
|
||||
}
|
||||
if op == PathfinderOp::Divide && st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if !f.paint.is_none())) {
|
||||
// Filled open contours keep their implicit closing edge. A zero-area line
|
||||
// stays a cutter; normalised area also catches zero-signed-area self crossings.
|
||||
@@ -498,6 +503,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
let mut paths = false;
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut lv = vec![];
|
||||
@@ -505,6 +511,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut made = vec![];
|
||||
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
|
||||
let Some((path, rule)) = node_path(l) else { continue };
|
||||
paths = true;
|
||||
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
|
||||
let off = po::offset_path(&base, delta, join, miter);
|
||||
if off.is_empty() {
|
||||
@@ -527,6 +534,9 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out.push(new.id);
|
||||
above.push((*id, vec![new]));
|
||||
}
|
||||
if out.is_empty() && paths {
|
||||
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Offset Path: select paths".into()));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -578,6 +578,11 @@ fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `id` a path made into a guide (View → Guides → Make Guides)?
|
||||
pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool {
|
||||
d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. }))
|
||||
}
|
||||
|
||||
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
@@ -1295,6 +1300,17 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
/// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back.
|
||||
fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec<NodeId>) -> std::result::Result<(), String> {
|
||||
let Some(d) = self.active_mut() else { return (spec.enabled)(self) };
|
||||
let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() });
|
||||
let r = (spec.enabled)(self);
|
||||
if let Some(d) = self.active_mut() {
|
||||
d.selection = old;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
@@ -1304,7 +1320,15 @@ impl Session {
|
||||
cmd::params::check_params(id, params)?;
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() { cmd::has_doc(self) } else { (spec.enabled)(self) };
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
|
||||
cmd::has_doc(self)
|
||||
} else if let Some(ids) = given_ids(spec.params, params) {
|
||||
// Objects given as `ids` stand in for the selection: the command is enabled as it is
|
||||
// with them selected (#1575).
|
||||
self.enabled_with_selection(spec, ids)
|
||||
} else {
|
||||
(spec.enabled)(self)
|
||||
};
|
||||
if let Err(why) = enabled {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
@@ -1482,10 +1506,18 @@ impl Session {
|
||||
/// Change only the selection (not an undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let guides_locked = self.menu.guides_locked;
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
|
||||
if guides_locked {
|
||||
let locked: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| is_guide_path(&st.doc, *id)).collect();
|
||||
for id in locked {
|
||||
st.selection.remove(id);
|
||||
}
|
||||
}
|
||||
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
|
||||
// the reference app: new art then goes beside it.
|
||||
if st.selection.objects != before
|
||||
@@ -1757,6 +1789,8 @@ mod tests_halftone;
|
||||
#[cfg(test)]
|
||||
mod tests_history_mem;
|
||||
#[cfg(test)]
|
||||
mod tests_ids_without_selection;
|
||||
#[cfg(test)]
|
||||
mod tests_inline;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
@@ -1942,3 +1976,10 @@ mod tests_widthprofiles;
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
||||
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
|
||||
/// key callers outside the app may pass it, [`cmd::params::takes_key`]).
|
||||
fn given_ids(description: &'static str, params: &Value) -> Option<Vec<NodeId>> {
|
||||
let ids = cmd::ids_param(params, "ids").filter(|ids| !ids.is_empty())?;
|
||||
cmd::params::takes_key(description, "ids").then_some(ids)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -671,6 +671,108 @@ fn image_trace_make_places_paths_over_the_image() {
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
|
||||
/// circle as its traced path; without the option it is a plain path.
|
||||
#[test]
|
||||
fn image_trace_shapes_makes_live_shapes() {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "shapes": true}})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let paths = traced_paths(&n);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let NodeKind::Path { path, live: Some(live), .. } = &paths[0].kind else { panic!("not a live shape: {:?}", paths[0].kind) };
|
||||
let vectorcraft_doc::LiveShape::Ellipse { w, h, .. } = **live else { panic!("{live:?}") };
|
||||
// A circle of r = 30 px, drawn twice the size.
|
||||
assert!(w == h && (w - 60.0).abs() < 0.75, "{w} × {h}");
|
||||
let bb = path.bounds().unwrap();
|
||||
assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5 && (bb.width() - 120.0).abs() < 1.5, "{bb:?}");
|
||||
assert_eq!(*path, live.to_path());
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
// Off, as documents saved before have it: a path.
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
assert!(matches!(traced_paths(&n)[0].kind, NodeKind::Path { live: None, .. }));
|
||||
}
|
||||
|
||||
/// Auto Grouping: a ring and the dot in its hole are a group (the ring first), the square beside
|
||||
/// them is not in it; the picture is the same as without, and the group traces again, expands
|
||||
/// and releases as any other.
|
||||
#[test]
|
||||
fn image_trace_auto_grouping_groups_what_is_inside() {
|
||||
let art = vectorcraft_trace::Raster::from_fn(120, 80, |x, y| {
|
||||
let d = (x as f64 + 0.5 - 40.0).hypot(y as f64 + 0.5 - 40.0);
|
||||
if d <= 6.0 || (90..110).contains(&x) && (30..50).contains(&y) {
|
||||
[200, 0, 0, 255]
|
||||
} else if (16.0..=30.0).contains(&d) {
|
||||
[0, 0, 160, 255]
|
||||
} else {
|
||||
[255, 255, 255, 255]
|
||||
}
|
||||
});
|
||||
let make = |grouping: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": grouping}})).unwrap();
|
||||
assert_eq!(r["paths"], 3);
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
(s, g)
|
||||
};
|
||||
let (mut s, g) = make(true);
|
||||
let items = traced_paths(&node(&s, g));
|
||||
assert_eq!(items.len(), 2);
|
||||
let group = items.iter().find(|n| matches!(n.kind, NodeKind::Group { .. })).expect("a group");
|
||||
let inside = group.children().unwrap();
|
||||
assert_eq!(inside.len(), 2);
|
||||
assert_eq!(inside[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 160)));
|
||||
assert_eq!(inside[1].appearance.fill_paint(), Paint::solid(Color::rgb8(200, 0, 0)));
|
||||
assert!(matches!(items.iter().find(|n| n.id != group.id).unwrap().kind, NodeKind::Path { .. }));
|
||||
// The same picture as without.
|
||||
let (flat, fg) = make(false);
|
||||
assert_eq!(traced_paths(&node(&flat, fg)).len(), 3);
|
||||
let shot = |s: &Session| {
|
||||
let img = render_doc(s, true);
|
||||
(0..120).flat_map(|x| (0..80).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(shot(&s), shot(&flat));
|
||||
// Traced again without the option, it is flat again; expanded, the groups stay.
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).len(), 3);
|
||||
s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": true}})).unwrap();
|
||||
let r = s.execute("imageTrace.expand", &json!({})).unwrap();
|
||||
let expanded = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert_eq!(expanded.children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Gradients: an image that is one linear gradient is one path filled with it, the gradient's line
|
||||
/// where the image lies; without the option it is bands of flat colour, as saved documents have it.
|
||||
#[test]
|
||||
fn image_trace_gradients_fills_with_a_gradient() {
|
||||
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
|
||||
let t = (f64::from(x) + 0.5) / 100.0;
|
||||
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
|
||||
});
|
||||
let make = |gradients: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": gradients}})).unwrap();
|
||||
traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())))
|
||||
};
|
||||
let paths = make(true);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
|
||||
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Linear);
|
||||
assert_eq!(g.gradient.stops.len(), 2);
|
||||
// Level, from the image's left edge (x = 50) to its right one (x = 250), at any height.
|
||||
let geom = g.geom.as_ref().unwrap();
|
||||
let (a, b) = if geom.start.x < geom.end.x { (geom.start, geom.end) } else { (geom.end, geom.start) };
|
||||
assert!((a.x - 50.0).abs() < 3.0 && (b.x - 250.0).abs() < 3.0 && (a.y - b.y).abs() < 2.0, "{geom:?}");
|
||||
let flat = make(false);
|
||||
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_release_and_expand() {
|
||||
let mut s = session();
|
||||
@@ -795,7 +897,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();
|
||||
@@ -1141,6 +1164,31 @@ fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]);
|
||||
}
|
||||
|
||||
/// #1533: two corners of a rectangle brought together by Average become one anchor on Join, in
|
||||
/// one undo step; anchors that aren't on one another leave Join as it was.
|
||||
#[test]
|
||||
fn join_merges_the_selected_anchors_average_brought_together() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 100.0, 80.0);
|
||||
let pts = |s: &Session| path(s, r).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::<Vec<_>>();
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
|
||||
assert!(s.execute("path.join", &json!({})).is_err(), "apart: nothing to merge, and the path is closed");
|
||||
s.execute("path.average", &json!({"axis": "both"})).unwrap();
|
||||
s.execute("path.join", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s), [(150.0, 100.0), (200.0, 180.0), (100.0, 180.0)]);
|
||||
assert!(path(&s, r).subpaths[0].closed);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert!(sel.objects == [r] && sel.anchors.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s).len(), 4);
|
||||
// The closing pair: the first and last anchors of a closed path.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 3]], "mode": "set"})).unwrap();
|
||||
s.execute("path.average", &json!({"axis": "both"})).unwrap();
|
||||
let before = pts(&s);
|
||||
s.execute("path.join", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s), before[..3]);
|
||||
}
|
||||
|
||||
/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors.
|
||||
#[test]
|
||||
fn join_takes_the_paths_and_the_ends_to_join() {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! #1575: commands that take `ids` work on the objects given, with nothing selected; the
|
||||
//! selection is left as it was.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn doc_with_rect() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_stand_in_for_the_selection() {
|
||||
for (cmd, params) in [
|
||||
("object.repeat.radial", json!({})),
|
||||
("edit.colors.invert", json!({})),
|
||||
("object.pattern.make", json!({"name": "P"})),
|
||||
("symbol.new", json!({"name": "S"})),
|
||||
("object.mesh.create", json!({})),
|
||||
("object.makePixelPerfect", json!({})),
|
||||
] {
|
||||
let (mut s, id) = doc_with_rect();
|
||||
let mut p = params;
|
||||
p["ids"] = json!([id]);
|
||||
let r = s.execute(cmd, &p);
|
||||
assert!(r.is_ok(), "{cmd}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_put_back_and_the_check_still_applies() {
|
||||
let (mut s, id) = doc_with_rect();
|
||||
// Disabled even with the object given: Make with Top Object needs two.
|
||||
let r = s.execute("object.envelope.makeWithTopObject", &json!({"ids": [id]}));
|
||||
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "the stand-in selection is put back");
|
||||
// Without ids, nothing selected is still nothing selected.
|
||||
let r = s.execute("object.repeat.radial", &json!({}));
|
||||
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_ids_key_counts() {
|
||||
let p = json!({"ids": [3]});
|
||||
assert_eq!(given_ids("{ids?: [id]}", &p), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{id?|ids?, name}", &p), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{grids: n, guides: bool}", &p), None);
|
||||
// A word in what the description says about another key isn't the key.
|
||||
assert_eq!(given_ids("{name: of the swatch the ids of the selection take}", &p), None);
|
||||
assert_eq!(given_ids("{ids?: [id]}", &json!({"ids": []})), None);
|
||||
}
|
||||
|
||||
/// Every command that names `ids` and is refused with nothing selected: given the object as `ids`
|
||||
/// it does what it does with the object selected (the same result or the same error, and the same
|
||||
/// document), so no command is let through to run on an empty selection.
|
||||
#[test]
|
||||
fn ids_do_what_the_selection_does_in_every_command_that_takes_them() {
|
||||
let mut checked = vec![];
|
||||
for spec in command_specs() {
|
||||
let (mut given, id) = doc_with_rect();
|
||||
if !cmd::params::takes_key(spec.params, "ids") || (spec.enabled)(&given).is_ok() {
|
||||
continue;
|
||||
}
|
||||
let (mut selected, _) = doc_with_rect();
|
||||
selected.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let (a, b) = (given.execute(spec.id, &json!({"ids": [id]})), selected.execute(spec.id, &json!({})));
|
||||
match (&a, &b) {
|
||||
(Ok(_), Ok(_)) => {}
|
||||
(Err(x), Err(y)) => assert_eq!(x.to_string(), y.to_string(), "{}", spec.id),
|
||||
_ => panic!("{}: with ids {a:?}, with the selection {b:?}", spec.id),
|
||||
}
|
||||
match (given.doc(), selected.doc()) {
|
||||
(Ok(g), Ok(s)) => assert!(g.doc == s.doc, "{}: the documents differ", spec.id),
|
||||
(g, s) => assert_eq!(g.is_ok(), s.is_ok(), "{}", spec.id),
|
||||
}
|
||||
checked.push((spec.id, a.is_ok()));
|
||||
}
|
||||
assert!(checked.iter().filter(|c| c.1).count() >= 20, "{checked:?}");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1249,6 +1249,26 @@ fn ruler_guides_and_lock() {
|
||||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
/// #1534: Lock Guides locks the paths made into guides too: they can't be selected (so neither
|
||||
/// moved, reshaped nor deleted), by a click, a marquee or Select All, and locking lets them go.
|
||||
#[test]
|
||||
fn locked_guides_lock_guide_paths() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 20.0, 10.0, 10.0));
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
}
|
||||
|
||||
fn guides(s: &Session) -> Vec<(bool, f64)> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
|
||||
}
|
||||
|
||||
@@ -893,3 +893,40 @@ fn divide_keeps_implicitly_closed_filled_paths_including_zero_signed_area() {
|
||||
assert!(pieces.iter().all(|n| n.appearance == source.appearance));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1570: Trim, Merge and Crop remove the objects' strokes; Merge then merges same-coloured
|
||||
/// fills whatever their strokes were.
|
||||
#[test]
|
||||
fn trim_merge_and_crop_remove_strokes() {
|
||||
for op in ["trim", "merge", "crop"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two_rects(&mut s);
|
||||
for (id, c) in [(a, Color::rgb(0.0, 0.0, 0.0)), (b, Color::rgb(1.0, 0.0, 0.0))] {
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(id).unwrap().appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(c), 3.0).into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let r = ids(&s.execute(&format!("object.pathfinder.{op}"), &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
let pieces = g.children().unwrap();
|
||||
assert!(!pieces.is_empty(), "{op}");
|
||||
assert!(pieces.iter().all(|c| c.appearance.stroke_width() == 0.0 && c.appearance.fill().is_some()), "{op} keeps a stroke");
|
||||
if op == "merge" {
|
||||
assert_eq!(pieces.len(), 1, "same fill, different strokes: merged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1590: an inset larger than the shape leaves nothing, and the error says so rather than asking
|
||||
/// for paths that are selected.
|
||||
#[test]
|
||||
fn offset_path_that_leaves_nothing_says_so() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let e = s.execute("object.path.offsetPath", &json!({"offset": -20})).unwrap_err().to_string();
|
||||
assert!(e.contains("leaves nothing") && !e.contains("select paths"), "{e}");
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -366,9 +366,9 @@ impl Out {
|
||||
/// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which
|
||||
/// clips nothing.
|
||||
fn clip(&mut self, path: BezPath, rule: FillRule) -> Option<Arc<Clip>> {
|
||||
let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..)));
|
||||
let around = path.bounding_box().inflate(0.5, 0.5);
|
||||
if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) {
|
||||
// Only a polygon that really covers the page: a diamond touching its edges has the page as
|
||||
// its bounding box but cuts its corners away (#1542).
|
||||
if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) {
|
||||
return None;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
|
||||
@@ -248,6 +248,12 @@ fn clips_become_clipping_groups() {
|
||||
// eoclip of a path.
|
||||
let o = objects(&read("10 10 moveto 30 10 lineto 30 30 lineto closepath eoclip newpath 0 0 50 50 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }));
|
||||
// #1542: a diamond touching the page edges has the page as its bounding box but still clips.
|
||||
let o = objects(&read("50 0 moveto 100 50 lineto 50 100 lineto 0 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
|
||||
// A turned square around the whole page clips nothing.
|
||||
let o = objects(&read("50 -60 moveto 160 50 lineto 50 160 lineto -60 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -467,6 +473,22 @@ fn the_bounding_box_comes_from_the_header_or_the_trailer() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("first of the file's 2 pages")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// #1552: a Type 42 font's glyph space is one unit an em, so its identity FontMatrix scaled to 12
|
||||
/// sets 12 pt type (a Type 1's 0.001 matrix needs the 1000-unit space).
|
||||
#[test]
|
||||
fn type_42_fonts_set_type_at_their_size() {
|
||||
for (kind, matrix) in [(42, "[1 0 0 1 0 0]"), (1, "[0.001 0 0 0.001 0 0]")] {
|
||||
let body = format!(
|
||||
"/T 10 dict begin /FontName /T def /FontType {kind} def /FontMatrix {matrix} def /Encoding StandardEncoding def currentdict end /T exch definefont pop \
|
||||
/T findfont 12 scalefont setfont 10 20 moveto (Hi) show"
|
||||
);
|
||||
let r = read(&body);
|
||||
let NodeKind::Text(t) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||||
let size = t.runs[0].style.size * t.xf.determinant().abs().sqrt();
|
||||
assert!((size - 12.0).abs() < 1e-6, "FontType {kind}: {size} pt");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_font_programs_are_skipped() {
|
||||
let body = "/MyFont 10 dict begin /FontName /MyFont def /FontMatrix [0.001 0 0 0.001 0 0] def currentdict end currentfile eexec\n\
|
||||
|
||||
@@ -403,7 +403,10 @@ impl Interp<'_> {
|
||||
let f = font.borrow();
|
||||
let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string());
|
||||
let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
Ok((name, m * Affine::scale(1000.0)))
|
||||
// Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em
|
||||
// (its FontMatrix, usually the identity, is already the em's).
|
||||
let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 };
|
||||
Ok((name, m * Affine::scale(units)))
|
||||
}
|
||||
|
||||
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -6,6 +6,47 @@ use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
|
||||
|
||||
use crate::path::{FillRule, PathData};
|
||||
|
||||
/// Does the filled area of `path` cover all of `r` shrunk by `tol` on every side? Answered for one
|
||||
/// convex polygon (a single subpath of straight segments), which covers the rect when it contains
|
||||
/// its four corners; any other path gives `false`, so a caller treats it as not covering.
|
||||
pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
for (i, el) in path.elements().iter().enumerate() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) if i == 0 => pts.push(p),
|
||||
PathEl::LineTo(p) if i > 0 => pts.push(p),
|
||||
PathEl::ClosePath if i > 0 => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if pts.len() > 1 && pts.first() == pts.last() {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
// Convex: every turn goes the same way (straight runs allowed).
|
||||
let n = pts.len();
|
||||
let mut sign = 0.0f64;
|
||||
for i in 0..n {
|
||||
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
|
||||
let cross = (b - a).cross(c - b);
|
||||
if cross.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = cross.signum();
|
||||
} else if cross.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let inner = r.inflate(-tol, -tol);
|
||||
sign != 0.0
|
||||
&& [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1), Point::new(inner.x0, inner.y1)]
|
||||
.into_iter()
|
||||
.all(|c| fill_contains(path, FillRule::NonZero, c))
|
||||
}
|
||||
|
||||
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
|
||||
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
|
||||
let w = closed(path).winding(p);
|
||||
@@ -94,6 +135,32 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
|
||||
#[test]
|
||||
fn polygon_covers_rect_only_for_convex_polygons_around_it() {
|
||||
let page = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let poly = |pts: &[(f64, f64)]| {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to(pts[0]);
|
||||
for &p in &pts[1..] {
|
||||
b.line_to(p);
|
||||
}
|
||||
b.close_path();
|
||||
b
|
||||
};
|
||||
assert!(polygon_covers_rect(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), page, 0.01));
|
||||
assert!(polygon_covers_rect(&poly(&[(50.0, -60.0), (160.0, 50.0), (50.0, 160.0), (-60.0, 50.0)]), page, 0.01), "turned, around it");
|
||||
// The page's bounding box, but its corners are cut away.
|
||||
assert!(!polygon_covers_rect(&poly(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]), page, 0.01));
|
||||
// Contains the corners but notches into the page: not convex.
|
||||
assert!(!polygon_covers_rect(&poly(&[(-1.0, -1.0), (101.0, -1.0), (101.0, 101.0), (50.0, 40.0), (-1.0, 101.0)]), page, 0.01));
|
||||
// Curves and several subpaths aren't judged.
|
||||
assert!(!polygon_covers_rect(&kurbo::Circle::new((50.0, 50.0), 200.0).to_path(0.1), page, 0.01));
|
||||
let mut two = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
two.extend(poly(&[(10.0, 10.0), (20.0, 10.0), (20.0, 20.0)]));
|
||||
assert!(!polygon_covers_rect(&two, page, 0.01));
|
||||
assert!(!polygon_covers_rect(&BezPath::new(), page, 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_and_stroke() {
|
||||
let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)).to_bezpath();
|
||||
|
||||
+65
-14
@@ -26,6 +26,8 @@ use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_sh
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// Mixed into an image's cache key when it is read white inside an alpha soft mask.
|
||||
const WHITE_KEY: u128 = 0x7717_e000_a1fa;
|
||||
/// The name of the paths text imports as.
|
||||
const TEXT_OUTLINES: &str = "<Text Outlines>";
|
||||
/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects.
|
||||
@@ -197,6 +199,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 +224,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 +273,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 +311,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),
|
||||
@@ -756,6 +790,10 @@ struct Builder<'p> {
|
||||
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
|
||||
pending: bool,
|
||||
nested: u32,
|
||||
/// Reading an alpha soft mask's art: images are read white with their own alpha, so the
|
||||
/// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape,
|
||||
/// rasterizes masks into such images).
|
||||
white_images: bool,
|
||||
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
|
||||
last_fill: Option<(NodeId, BezPath)>,
|
||||
/// What each line of type made tells beyond its text object ([`crate::import_lines`]).
|
||||
@@ -898,6 +936,7 @@ impl<'p> Builder<'p> {
|
||||
marked: vec![],
|
||||
pending: false,
|
||||
nested: 0,
|
||||
white_images: false,
|
||||
last_fill: None,
|
||||
lines: HashMap::new(),
|
||||
}
|
||||
@@ -1267,7 +1306,10 @@ impl<'p> Builder<'p> {
|
||||
return s.clone();
|
||||
}
|
||||
let page = self.page;
|
||||
let spec = match self.nested(page, |b| m.interpret(b)) {
|
||||
let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha);
|
||||
let art = self.nested(page, |b| m.interpret(b));
|
||||
self.white_images = saved;
|
||||
let spec = match art {
|
||||
Some(art) => {
|
||||
let [r, g, b, _] = m.background_color().to_rgba().components();
|
||||
let backdrop = luminance(&Color::rgb(r, g, b));
|
||||
@@ -1298,10 +1340,15 @@ impl<'p> Builder<'p> {
|
||||
{
|
||||
return Some(Paint::Pattern { pattern: name.clone(), xf });
|
||||
}
|
||||
// A swatch is shared with uses outside an alpha mask, and pattern paints keep their
|
||||
// colours there: its images are read as they are.
|
||||
let white = std::mem::take(&mut self.white_images);
|
||||
let art = self.nested(Rect::ZERO, |b| {
|
||||
// A cell whose content can't be decoded is empty.
|
||||
let _ = t.interpret(b, Affine::IDENTITY, stroke);
|
||||
})?;
|
||||
});
|
||||
self.white_images = white;
|
||||
let art = art?;
|
||||
let sig = paints(&art);
|
||||
let entry = self.pattern_keys.entry(key).or_default();
|
||||
entry.0 += 1;
|
||||
@@ -1722,14 +1769,12 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
(*isolate, *knockout) = (i, k);
|
||||
}
|
||||
}
|
||||
let bb = clip_path.path.bounding_box();
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them.
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them. Only
|
||||
// a polygon that really covers it counts: a diamond touching the page edges has the page
|
||||
// as its bounding box but cuts its corners away (#1542).
|
||||
let redundant = clip_path.path.elements().len() <= 6
|
||||
&& bb.x0 <= self.page.x0 + 0.01
|
||||
&& bb.y0 <= self.page.y0 + 0.01
|
||||
&& bb.x1 >= self.page.x1 - 0.01
|
||||
&& bb.y1 >= self.page.y1 - 0.01
|
||||
&& self.page.area() > 0.0;
|
||||
&& self.page.area() > 0.0
|
||||
&& vectorcraft_geom::hit::polygon_covers_rect(&clip_path.path, self.page, 0.01);
|
||||
let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) };
|
||||
self.stack.push(Frame::new(kind));
|
||||
}
|
||||
@@ -1802,12 +1847,14 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.pending = false;
|
||||
match image {
|
||||
Image::Raster(r) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&r);
|
||||
let white = self.white_images;
|
||||
// Inside an alpha mask the same image is read again, white (its own cache key).
|
||||
let key = hayro_interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 };
|
||||
let st = r.stream();
|
||||
let (w, h) = (r.width(), r.height());
|
||||
// CMYK images keep their samples (read once per image).
|
||||
let known = self.cmyk_keys.contains(&key);
|
||||
let cmyk = if known {
|
||||
let known = !white && self.cmyk_keys.contains(&key);
|
||||
let cmyk = if known || white {
|
||||
None
|
||||
} else {
|
||||
crate::import_image::cmyk(st, w, h).unwrap_or_else(|why| {
|
||||
@@ -1826,7 +1873,8 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let dict = st.dict();
|
||||
let filters = st.filters();
|
||||
let cs_ok = dict.get::<Name<'_>>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray"));
|
||||
let jpeg = filters.len() == 1
|
||||
let jpeg = !white
|
||||
&& filters.len() == 1
|
||||
&& matches!(filters[0], hayro_syntax::Filter::DctDecode)
|
||||
&& cs_ok
|
||||
&& !dict.contains_key(b"SMask")
|
||||
@@ -1843,6 +1891,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let (w, h, sf) = (img.width(), img.height(), img.scale_factors());
|
||||
let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]);
|
||||
let rgba: Vec<u8> = match img {
|
||||
_ if white => a.into_iter().flat_map(|a| [255, 255, 255, a]).collect(),
|
||||
ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
|
||||
ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(),
|
||||
};
|
||||
@@ -1858,11 +1907,13 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf);
|
||||
}
|
||||
Image::Stencil(s) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&s);
|
||||
let white = self.white_images;
|
||||
let key = hayro_interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 };
|
||||
let mut decoded: Option<Decoded> = None;
|
||||
s.with_stencil(
|
||||
|luma, paint| {
|
||||
let rgb = match paint {
|
||||
_ if white => [255, 255, 255, 255],
|
||||
hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
|
||||
_ => [0, 0, 0, 255],
|
||||
};
|
||||
|
||||
@@ -78,6 +78,58 @@ fn the_mask(d: &Document) -> (Node, OpacityMask) {
|
||||
(n, m)
|
||||
}
|
||||
|
||||
/// The two pixels of the image a soft mask of kind `s` draws: black, opaque on the left and clear
|
||||
/// on the right (its own `/SMask`).
|
||||
fn mask_image_pixels(s: &str) -> [[u8; 4]; 2] {
|
||||
let g = first_extra(1);
|
||||
let group = stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Im0 {} 0 R >> >>",
|
||||
g + 1
|
||||
),
|
||||
"q 100 0 0 100 0 0 cm /Im0 Do Q",
|
||||
);
|
||||
let image = stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /SMask {} 0 R",
|
||||
g + 2
|
||||
),
|
||||
"000000000000>",
|
||||
);
|
||||
let smask =
|
||||
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>");
|
||||
let bytes = one_page(
|
||||
"/GS0 gs 1 0 0 rg 0 0 100 100 re f",
|
||||
&format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /{s} /G {g} 0 R >> >> >>"),
|
||||
&[&group, &image, &smask],
|
||||
);
|
||||
let d = open(&bytes);
|
||||
let (_, m) = the_mask(&d);
|
||||
let mut keys = vec![];
|
||||
m.art.walk(&mut |n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
keys.push(im.key.clone());
|
||||
}
|
||||
});
|
||||
let [key] = keys.as_slice() else { panic!("one image in the mask art: {keys:?}") };
|
||||
let px = image::load_from_memory(&d.images[key].bytes).unwrap().to_rgba8();
|
||||
[px.get_pixel(0, 0).0, px.get_pixel(1, 0).0]
|
||||
}
|
||||
|
||||
/// An alpha soft mask whose art is an image with its own alpha (some PDF writers rasterize masks
|
||||
/// so): the image is read white with that alpha, so the mask's luminance is the alpha, not the
|
||||
/// image's colour (ISO 32000-1 11.5.2: an alpha mask takes the group's alpha, not its colour).
|
||||
#[test]
|
||||
fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() {
|
||||
assert_eq!(mask_image_pixels("Alpha"), [[255, 255, 255, 255], [255, 255, 255, 0]]);
|
||||
}
|
||||
|
||||
/// A luminosity soft mask takes the group's colour (ISO 32000-1 11.5.3): its image stays black.
|
||||
#[test]
|
||||
fn a_luminosity_soft_mask_of_an_image_keeps_the_images_colour() {
|
||||
assert_eq!(mask_image_pixels("Luminosity"), [[0, 0, 0, 255], [0, 0, 0, 0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_luminosity_soft_mask_becomes_an_opacity_mask() {
|
||||
let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));
|
||||
@@ -544,3 +596,16 @@ fn glyphs_closed_up_by_half_an_em_stay_one_line() {
|
||||
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), "Tight");
|
||||
}
|
||||
|
||||
/// #1542: a clip only counts as covering the page when it does; a diamond whose bounding box is
|
||||
/// the page still clips, a page rectangle or a turned square around it doesn't make a group.
|
||||
#[test]
|
||||
fn clips_with_the_page_as_bounding_box_still_clip() {
|
||||
let clipped = |clip: &str| {
|
||||
let d = open(&one_page(&format!("q {clip} W n 1 0.5 0 rg 0 0 100 100 re f Q"), "", &[]));
|
||||
all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))
|
||||
};
|
||||
assert!(clipped("50 0 m 100 50 l 50 100 l 0 50 l h"), "diamond");
|
||||
assert!(!clipped("0 0 100 100 re"), "the page");
|
||||
assert!(!clipped("50 -60 m 160 50 l 50 160 l -60 50 l h"), "a turned square around the page");
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -341,9 +341,11 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
|
||||
let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect();
|
||||
// k-means: move each entry to the mean of the cells nearest to it.
|
||||
for _ in 0..3 {
|
||||
// Centres move between passes, not within one: build the spatial index once per pass.
|
||||
let index = PaletteIndex::new(&palette, w);
|
||||
let mut sums = vec![([0f64; 3], 0f64); palette.len()];
|
||||
for c in &cells {
|
||||
let i = nearest(&palette, c.mean, w);
|
||||
let i = index.nearest(c.mean);
|
||||
for ch in 0..3 {
|
||||
sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64;
|
||||
}
|
||||
@@ -371,8 +373,137 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
|
||||
out
|
||||
}
|
||||
|
||||
fn nearest(palette: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
|
||||
palette.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
|
||||
/// An exact 3-D nearest-neighbour index for the weighted RGB distance used by palette export.
|
||||
///
|
||||
/// Balanced at the weighted widest axis. Every subtree carries tight channel bounds, so a
|
||||
/// branch-and-bound search skips it when even its nearest *possible* colour is farther than the
|
||||
/// best one found. Equal distances still choose the earliest palette index (as a linear scan did).
|
||||
/// For very small palettes a scan is cheaper than traversing the tree.
|
||||
struct PaletteNode {
|
||||
index: usize,
|
||||
lo: [f32; 3],
|
||||
hi: [f32; 3],
|
||||
left: Option<usize>,
|
||||
right: Option<usize>,
|
||||
}
|
||||
|
||||
struct PaletteIndex<'a> {
|
||||
colors: &'a [[f32; 3]],
|
||||
weights: [f32; 3],
|
||||
nodes: Vec<PaletteNode>,
|
||||
root: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> PaletteIndex<'a> {
|
||||
const SCAN_CUTOFF: usize = 12;
|
||||
|
||||
fn new(colors: &'a [[f32; 3]], weights: [f32; 3]) -> Self {
|
||||
let mut nodes = if colors.len() <= Self::SCAN_CUTOFF { Vec::new() } else { Vec::with_capacity(colors.len()) };
|
||||
let root = if colors.len() <= Self::SCAN_CUTOFF {
|
||||
None
|
||||
} else {
|
||||
let mut indices: Vec<usize> = (0..colors.len()).collect();
|
||||
Self::build(colors, weights, &mut indices, &mut nodes)
|
||||
};
|
||||
Self { colors, weights, nodes, root }
|
||||
}
|
||||
|
||||
// Median partitioning takes O(k log k) time and space for k palette entries.
|
||||
fn build(colors: &[[f32; 3]], w: [f32; 3], ids: &mut [usize], nodes: &mut Vec<PaletteNode>) -> Option<usize> {
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
|
||||
for &id in ids.iter() {
|
||||
let Some(c) = colors.get(id) else { continue };
|
||||
for axis in 0..3 {
|
||||
lo[axis] = lo[axis].min(c[axis]);
|
||||
hi[axis] = hi[axis].max(c[axis]);
|
||||
}
|
||||
}
|
||||
let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0);
|
||||
let mid = ids.len() / 2;
|
||||
ids.select_nth_unstable_by(mid, |&a, &b| {
|
||||
let ac = colors.get(a).map_or(0.0, |c| c[axis]);
|
||||
let bc = colors.get(b).map_or(0.0, |c| c[axis]);
|
||||
ac.total_cmp(&bc).then(a.cmp(&b))
|
||||
});
|
||||
let (before, rest) = ids.split_at_mut(mid);
|
||||
let (pivot, after) = rest.split_first_mut()?;
|
||||
let at = nodes.len();
|
||||
nodes.push(PaletteNode { index: *pivot, lo, hi, left: None, right: None });
|
||||
let left = Self::build(colors, w, before, nodes);
|
||||
let right = Self::build(colors, w, after, nodes);
|
||||
if let Some(n) = nodes.get_mut(at) {
|
||||
n.left = left;
|
||||
n.right = right;
|
||||
}
|
||||
Some(at)
|
||||
}
|
||||
|
||||
// Lower bound on *the same f32 weighted squared distance* as `dist`: avoid transforming
|
||||
// coordinates, which would change rounding and potentially break identical-palette ties.
|
||||
fn lower_bound(&self, id: usize, c: [f32; 3]) -> f32 {
|
||||
let Some(n) = self.nodes.get(id) else { return f32::INFINITY };
|
||||
(0..3)
|
||||
.map(|i| {
|
||||
let gap = if c[i] < n.lo[i] {
|
||||
n.lo[i] - c[i]
|
||||
} else if c[i] > n.hi[i] {
|
||||
c[i] - n.hi[i]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(self.weights[i] * gap).powi(2)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn search(&self, at: usize, c: [f32; 3], best: &mut (f32, usize)) {
|
||||
let Some(n) = self.nodes.get(at) else { return };
|
||||
let Some(&p) = self.colors.get(n.index) else { return };
|
||||
let d = dist(p, c, self.weights);
|
||||
if d < best.0 || (d == best.0 && n.index < best.1) {
|
||||
*best = (d, n.index);
|
||||
}
|
||||
// Search the more promising box first, tightening the bound for the second box.
|
||||
let left = n.left.map(|id| (id, self.lower_bound(id, c)));
|
||||
let right = n.right.map(|id| (id, self.lower_bound(id, c)));
|
||||
match (left, right) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (first, second) = if a.1 <= b.1 { (a, b) } else { (b, a) };
|
||||
if first.1 <= best.0 {
|
||||
self.search(first.0, c, best);
|
||||
}
|
||||
// `<=` is essential: an equal-distance colour may have an earlier index.
|
||||
if second.1 <= best.0 {
|
||||
self.search(second.0, c, best);
|
||||
}
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => {
|
||||
if a.1 <= best.0 {
|
||||
self.search(a.0, c, best);
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest(&self, c: [f32; 3]) -> usize {
|
||||
if self.colors.len() <= Self::SCAN_CUTOFF {
|
||||
return self
|
||||
.colors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|a, b| dist(*a.1, c, self.weights).total_cmp(&dist(*b.1, c, self.weights)))
|
||||
.map_or(0, |(i, _)| i);
|
||||
}
|
||||
let mut best = (f32::INFINITY, 0);
|
||||
if let Some(root) = self.root {
|
||||
self.search(root, c, &mut best);
|
||||
}
|
||||
best.1
|
||||
}
|
||||
}
|
||||
|
||||
/// The 8×8 ordered-dither threshold at (x, y), in 0..1.
|
||||
@@ -398,19 +529,20 @@ fn noise(x: usize, y: usize) -> f32 {
|
||||
/// names (transparent) get index 0 and take no diffused error.
|
||||
fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec<u8> {
|
||||
let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect();
|
||||
let index = PaletteIndex::new(&pal, w);
|
||||
// Nearest entries by colour: exact colours without dithering, a 6-bit grid with it.
|
||||
let mut exact: HashMap<[u8; 3], u8> = HashMap::new();
|
||||
let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }];
|
||||
let mut lookup = |c: [f32; 3]| -> u8 {
|
||||
let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8);
|
||||
if grid.is_empty() {
|
||||
return *exact.entry(q).or_insert_with(|| nearest(&pal, c, w) as u8);
|
||||
return *exact.entry(q).or_insert_with(|| index.nearest(c) as u8);
|
||||
}
|
||||
let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2;
|
||||
match grid.get(key) {
|
||||
Some(&v) if v != u16::MAX => v as u8,
|
||||
_ => {
|
||||
let i = nearest(&pal, c, w) as u8;
|
||||
let i = index.nearest(c) as u8;
|
||||
if let Some(g) = grid.get_mut(key) {
|
||||
*g = i as u16;
|
||||
}
|
||||
@@ -466,3 +598,83 @@ fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dithe
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nearest_tests {
|
||||
use super::*;
|
||||
|
||||
// The original definition, retained only as a parity oracle for the spatial index.
|
||||
fn linear(colors: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
|
||||
colors.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
|
||||
}
|
||||
|
||||
fn next(seed: &mut u32) -> u32 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 17;
|
||||
*seed ^= *seed << 5;
|
||||
*seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_nearest_matches_linear_for_palette_sizes_and_weights() {
|
||||
let mut seed = 0x2ab4_1017;
|
||||
for w in [[1.0; 3], [0.55, 0.77, 0.34], [0.1, 1.0, 0.01]] {
|
||||
for size in [0, 1, 2, 8, 12, 13, 16, 17, 32, 64, 128, 256] {
|
||||
// Include duplicated colours, close k-means-style floating-point centres,
|
||||
// and a wide colour gamut. Query points are fractional, as with dithering.
|
||||
let colors: Vec<[f32; 3]> =
|
||||
(0..size)
|
||||
.map(|i| {
|
||||
if i % 11 == 0 {
|
||||
[127.0, 127.0, 127.0]
|
||||
} else {
|
||||
[0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let index = PaletteIndex::new(&colors, w);
|
||||
for _ in 0..500 {
|
||||
let c = [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0));
|
||||
assert_eq!(index.nearest(c), linear(&colors, c, w), "size {size}, weights {w:?}, query {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_distance_always_chooses_first_palette_entry() {
|
||||
// Enough entries to exercise the tree, even when they share one bounding box.
|
||||
let mut colors = vec![[128.0, 128.0, 128.0]; 256];
|
||||
colors[0] = [0.0, 128.0, 128.0];
|
||||
colors[255] = [2.0, 128.0, 128.0];
|
||||
let index = PaletteIndex::new(&colors, [1.0; 3]);
|
||||
assert_eq!(index.nearest([1.0, 128.0, 128.0]), 0, "equal distance to indices 0 and 255");
|
||||
assert_eq!(index.nearest([128.0; 3]), 1, "equal among 254 identical colours");
|
||||
let colors = vec![[10.0, 10.0, 10.0]; 256];
|
||||
assert_eq!(PaletteIndex::new(&colors, [0.55, 0.77, 0.34]).nearest([10.0; 3]), 0);
|
||||
}
|
||||
|
||||
/// Opt-in microbenchmark: compares the same nearest-neighbour queries, without image IO.
|
||||
#[test]
|
||||
#[ignore = "run with cargo test -p vectorcraft-render nearest_palette_benchmark --release -- --ignored --nocapture"]
|
||||
fn nearest_palette_benchmark() {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut seed = 0x87bb_7641;
|
||||
let colors: Vec<[f32; 3]> = (0..256).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 256) as f32)).collect();
|
||||
let queries: Vec<[f32; 3]> = (0..100_000).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))).collect();
|
||||
let w = [0.55, 0.77, 0.34];
|
||||
let start = Instant::now();
|
||||
let baseline: Vec<_> = queries.iter().map(|&c| linear(&colors, c, w)).collect();
|
||||
let old = start.elapsed();
|
||||
let start = Instant::now();
|
||||
let index = PaletteIndex::new(&colors, w);
|
||||
let improved: Vec<_> = queries.iter().map(|&c| index.nearest(c)).collect();
|
||||
let new = start.elapsed();
|
||||
assert_eq!(baseline, improved);
|
||||
eprintln!(
|
||||
"100k nearest-colour queries, 256 entries: linear {old:?}, spatial index {new:?}, speedup {:.2}x",
|
||||
old.as_secs_f64() / new.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,9 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>
|
||||
// On whole pixels at full size the cheapest sampler is exact.
|
||||
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
// The raster maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern,
|
||||
// radial or freeform gradient) would resample it (#1536).
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
@@ -617,6 +620,7 @@ impl Renderer {
|
||||
ctx.set_transform(Affine::translate((x, y)));
|
||||
ctx.set_blend_mode(blend_mode(mode));
|
||||
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
|
||||
|
||||
@@ -96,6 +96,9 @@ pub struct RenderOptions {
|
||||
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
|
||||
/// Color), RGB.
|
||||
pub tile_edge: [u8; 3],
|
||||
/// Colour and style of the paths made into guides (Preferences › Guides & Grid, as the ruler
|
||||
/// guides the UI draws): RGB, and whether they are dotted.
|
||||
pub guide: ([u8; 3], bool),
|
||||
/// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's
|
||||
/// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent).
|
||||
pub mask_view: Option<NodeId>,
|
||||
@@ -201,6 +204,7 @@ impl Default for RenderOptions {
|
||||
trim: false,
|
||||
skip_templates: false,
|
||||
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
|
||||
guide: ([0x4a, 0xd8, 0xff], false),
|
||||
mask_view: None,
|
||||
highlight_substitutions: false,
|
||||
anti_alias: AntiAlias::Art,
|
||||
@@ -1158,7 +1162,17 @@ impl Renderer {
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
let bp = path.to_bezpath();
|
||||
if *guide {
|
||||
self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]);
|
||||
let ([r, g, b], dots) = f.opts.guide;
|
||||
if dots {
|
||||
let mut screen = bp.clone();
|
||||
screen.apply_affine(f.view);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.5).with_dashes(0.0, [1.5, 2.5]));
|
||||
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
|
||||
ctx.stroke_path(&screen);
|
||||
} else {
|
||||
self.hairline(ctx, f, &bp, [r, g, b, 255]);
|
||||
}
|
||||
} else {
|
||||
self.draw_shape(ctx, f, n, &bp, *rule);
|
||||
}
|
||||
|
||||
@@ -274,6 +274,58 @@ mod tests {
|
||||
p[0] > 200 && p[1] > 200 && p[2] > 200
|
||||
}
|
||||
|
||||
/// #1536: a raster effect drawn after a pattern fill is the same as without the pattern (the
|
||||
/// pattern's paint transform must not resample the effect's raster).
|
||||
#[test]
|
||||
fn effects_after_a_pattern_fill_keep_their_place_and_size() {
|
||||
let fx = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
|
||||
for effect in [
|
||||
fx("blur.gaussian", serde_json::json!({"radius": 4.0})),
|
||||
fx("stylize.dropShadow", serde_json::json!({"x": 6, "y": 6, "blur": 3, "opacity": 100})),
|
||||
] {
|
||||
let make = |with_pattern: bool| {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let l = d.layers[0].id;
|
||||
let filled = d.children(Some(l)).unwrap()[0].id;
|
||||
d.remove(filled).unwrap();
|
||||
if with_pattern {
|
||||
let id = d.alloc_id();
|
||||
let n = Node::path(
|
||||
id,
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)),
|
||||
Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0),
|
||||
);
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(
|
||||
id,
|
||||
shapes::ellipse(Rect::new(120.0, 110.0, 180.0, 170.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(effect.clone());
|
||||
let at = d.children(Some(l)).map_or(0, Vec::len);
|
||||
d.insert(Some(l), at, n).unwrap();
|
||||
d
|
||||
};
|
||||
// Supersampled, as raster exports draw.
|
||||
let one = |d: &Document| {
|
||||
let mut r = Renderer::new();
|
||||
r.render(
|
||||
d,
|
||||
200,
|
||||
200,
|
||||
Affine::IDENTITY,
|
||||
&RenderOptions { background: Some([255, 255, 255, 255]), supersample: true, ..Default::default() },
|
||||
)
|
||||
};
|
||||
let (with, without) = (one(&make(true)), one(&make(false)));
|
||||
for (x, y) in [(150, 140), (125, 140), (176, 160), (150, 172), (184, 176)] {
|
||||
assert_eq!(with.pixel(x, y), without.pixel(x, y), "{} at ({x}, {y})", effect.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_is_periodic() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
|
||||
@@ -67,3 +67,31 @@ fn swatch_bounds_outline_the_period_dashed() {
|
||||
let off = render(&editing(tt, false, false), Some([0, 200, 0]));
|
||||
assert!((51..70).all(|x| is_white(off.pixel(x, 90))));
|
||||
}
|
||||
|
||||
/// #1534: a path made into a guide is drawn in the guide colour and style (Preferences › Guides &
|
||||
/// Grid), as ruler guides are: a line, or dots with gaps between them.
|
||||
#[test]
|
||||
fn guide_paths_take_the_guide_color_and_style() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let (layer, id) = (d.layers[0].id, d.alloc_id());
|
||||
let mut n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { guide, .. } = &mut n.kind {
|
||||
*guide = true;
|
||||
}
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let draw = |guide| {
|
||||
let opts = RenderOptions { background: Some([255; 4]), guide, ..Default::default() };
|
||||
Renderer::new().render(&d, 120, 120, Affine::translate((50.5, 50.5)), &opts)
|
||||
};
|
||||
// Along the top edge of the square (y = 50), between its corners: the pixels the red covers.
|
||||
let inked = |r: &Rendered| (55..105).filter(|x| r.pixel(*x, 50)[1] < 64).count();
|
||||
let line = draw(([200, 0, 0], false));
|
||||
let p = line.pixel(80, 50);
|
||||
assert!(p[0] > 150 && p[1] < 100 && p[2] < 100, "{p:?}");
|
||||
assert_eq!(inked(&line), 50);
|
||||
let dotted = draw(([200, 0, 0], true));
|
||||
let dots = inked(&dotted);
|
||||
assert!((10..40).contains(&dots), "{dots}: {:?}", (45..70).map(|x| dotted.pixel(x, 50)[1]).collect::<Vec<_>>());
|
||||
let default = draw(RenderOptions::default().guide);
|
||||
assert!((55..105).all(|x| !is_white(default.pixel(x, 50))), "a line by default");
|
||||
}
|
||||
@@ -50,6 +50,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
body: String::new(),
|
||||
defs: String::new(),
|
||||
classes: Vec::new(),
|
||||
class_ids: HashMap::new(),
|
||||
used_ids: HashSet::new(),
|
||||
names: HashMap::new(),
|
||||
depth: 1,
|
||||
@@ -259,8 +260,10 @@ struct Writer<'a> {
|
||||
xf: Affine,
|
||||
body: String,
|
||||
defs: String,
|
||||
/// Unique CSS declaration blocks; class name is `cls-{index + 1}`.
|
||||
/// Unique CSS declaration blocks, in first-use order (used for deterministic output).
|
||||
classes: Vec<String>,
|
||||
/// 1-based class/entity number by declaration; avoids a linear scan for each style.
|
||||
class_ids: HashMap<String, usize>,
|
||||
used_ids: HashSet<String>,
|
||||
names: HashMap<NodeId, String>,
|
||||
depth: usize,
|
||||
@@ -657,13 +660,13 @@ impl Writer<'_> {
|
||||
|
||||
/// The 1-based number of a distinct declaration block (a CSS class or a style entity).
|
||||
fn class(&mut self, decl: String) -> usize {
|
||||
match self.classes.iter().position(|c| *c == decl) {
|
||||
Some(i) => i + 1,
|
||||
None => {
|
||||
self.classes.push(decl);
|
||||
self.classes.len()
|
||||
}
|
||||
if let Some(&i) = self.class_ids.get(decl.as_str()) {
|
||||
return i;
|
||||
}
|
||||
let i = self.classes.len() + 1;
|
||||
self.class_ids.insert(decl.clone(), i);
|
||||
self.classes.push(decl);
|
||||
i
|
||||
}
|
||||
|
||||
/// `<metadata>`: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the
|
||||
|
||||
@@ -21,9 +21,11 @@ use text::TextSlots;
|
||||
pub(crate) use fx::BLEND;
|
||||
|
||||
mod css;
|
||||
mod css_transform;
|
||||
mod files;
|
||||
mod fx;
|
||||
mod spread;
|
||||
mod symbol_units;
|
||||
mod text;
|
||||
mod vector_effect;
|
||||
|
||||
@@ -464,11 +466,15 @@ impl Edits {
|
||||
/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded,
|
||||
/// to come back hidden; one something links to (a `<use>` template) stays as it is;
|
||||
/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]);
|
||||
/// * linked files are read ([`files`]).
|
||||
/// * linked files are read ([`files`]);
|
||||
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]).
|
||||
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
let mut found = Found::default();
|
||||
// (`:` for prefixed elements such as `<svg:image>`.)
|
||||
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t)) {
|
||||
let css_transform = has_css_transform(svg);
|
||||
if !css_transform
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t))
|
||||
{
|
||||
return (svg.into(), found);
|
||||
}
|
||||
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
||||
@@ -507,9 +513,20 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
}
|
||||
}
|
||||
found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings);
|
||||
if !symbols.is_empty() && svg.contains('%') {
|
||||
edits.list.extend(symbol_units::rewrites(&xml));
|
||||
}
|
||||
if css_transform {
|
||||
edits.list.extend(css_transform::rewrites(svg, &xml));
|
||||
}
|
||||
(edits.apply(svg), found)
|
||||
}
|
||||
|
||||
/// Is there a `transform` CSS declaration in `svg` (`transform` followed by `:`)?
|
||||
fn has_css_transform(svg: &str) -> bool {
|
||||
svg.match_indices("transform").any(|(i, t)| svg.get(i + t.len()..).is_some_and(|s| s.trim_start().starts_with(':')))
|
||||
}
|
||||
|
||||
/// The elements that are objects (what [`hidden_objects`] shows).
|
||||
const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"];
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ use usvg::roxmltree;
|
||||
|
||||
pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
|
||||
|
||||
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag.
|
||||
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
|
||||
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
|
||||
fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
|
||||
s.split(';')
|
||||
.filter_map(|d| {
|
||||
@@ -27,9 +28,28 @@ fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
|
||||
};
|
||||
Some((k.trim().to_ascii_lowercase(), v.to_string(), important))
|
||||
})
|
||||
.flat_map(|(k, v, important)| match k.as_str() {
|
||||
"font" => font_longhands(&v).into_iter().map(|(k, v)| (k.to_string(), v, important)).collect(),
|
||||
_ => vec![(k, v, important)],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The longhands of a `font` shorthand: those it leaves out back to their initial values. None
|
||||
/// when it doesn't parse (it is then ignored, as in CSS).
|
||||
fn font_longhands(v: &str) -> Vec<(&'static str, String)> {
|
||||
let Ok(f) = svgtypes::FontShorthand::from_str(v) else { return Vec::new() };
|
||||
let or = |o: Option<&str>| o.unwrap_or("normal").to_string();
|
||||
vec![
|
||||
("font-style", or(f.font_style)),
|
||||
("font-variant", or(f.font_variant)),
|
||||
("font-weight", or(f.font_weight)),
|
||||
("font-stretch", or(f.font_stretch)),
|
||||
("font-size", f.font_size.to_string()),
|
||||
("font-family", f.font_family.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
/// One compound selector (`text.a.b#c[x=y]`).
|
||||
#[derive(Debug, Default)]
|
||||
struct Compound {
|
||||
@@ -343,6 +363,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_shorthand_sets_its_longhands() {
|
||||
let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense");
|
||||
let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::<Vec<_>>();
|
||||
assert_eq!(get("font-style"), [("italic", false)]);
|
||||
assert_eq!(get("font-weight"), [("bold", false), ("300", true)]);
|
||||
assert_eq!(get("font-size"), [("30px", false)]);
|
||||
assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]);
|
||||
assert_eq!(get("font-variant"), [("normal", false)]);
|
||||
assert_eq!(get("fill"), [("red", false)]);
|
||||
assert!(get("font").is_empty(), "the shorthand itself is not kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_selectors_are_dropped() {
|
||||
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", ""] {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! CSS `transform` declarations in SVG syntax, for usvg.
|
||||
//!
|
||||
//! usvg takes the `transform` property (from a `style` attribute or a `<style>` rule) as a
|
||||
//! presentation attribute that overrides the element's `transform` attribute, but reads its value
|
||||
//! as SVG, without CSS units: `translate(100px, 0)` or `rotate(45deg)` fails, and the element is
|
||||
//! left with no transform at all (#1557). Before usvg runs, such values are rewritten in SVG
|
||||
//! syntax: lengths in `px` (or unitless), angles in `deg`, `rad`, `grad` or `turn`, and the CSS
|
||||
//! functions SVG lacks (`translateX`, `scaleY`, `skew`…) spelled with SVG's. A value that can't be
|
||||
//! (percentages, `em`, 3D functions) is left as it is.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
use std::ops::Range;
|
||||
|
||||
use usvg::roxmltree;
|
||||
|
||||
use super::css::XNode;
|
||||
|
||||
/// The CSS transform `value` in SVG syntax; `None` when it has nothing to rewrite or can't be.
|
||||
pub(super) fn to_svg(value: &str) -> Option<String> {
|
||||
let v = value.trim();
|
||||
if v.eq_ignore_ascii_case("none") {
|
||||
return Some("matrix(1 0 0 1 0 0)".into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut rest = v;
|
||||
while !rest.trim_start().is_empty() {
|
||||
rest = rest.trim_start();
|
||||
let open = rest.find('(')?;
|
||||
let close = rest.find(')')?;
|
||||
let name = rest.get(..open)?.trim().to_ascii_lowercase();
|
||||
let args: Vec<&str> = rest.get(open + 1..close)?.split(|c: char| c == ',' || c.is_whitespace()).filter(|a| !a.is_empty()).collect();
|
||||
let len = |i: usize| args.get(i).and_then(|a| length(a));
|
||||
let ang = |i: usize| args.get(i).and_then(|a| angle(a));
|
||||
let num = |i: usize| args.get(i).and_then(|a| a.parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let f = match (name.as_str(), args.len()) {
|
||||
("matrix", 6) => format!("matrix({} {} {} {} {} {})", num(0)?, num(1)?, num(2)?, num(3)?, len(4)?, len(5)?),
|
||||
("translate", 1) => format!("translate({} 0)", len(0)?),
|
||||
("translate", 2) => format!("translate({} {})", len(0)?, len(1)?),
|
||||
("translatex", 1) => format!("translate({} 0)", len(0)?),
|
||||
("translatey", 1) => format!("translate(0 {})", len(0)?),
|
||||
("scale", 1) => format!("scale({})", num(0)?),
|
||||
("scale", 2) => format!("scale({} {})", num(0)?, num(1)?),
|
||||
("scalex", 1) => format!("scale({} 1)", num(0)?),
|
||||
("scaley", 1) => format!("scale(1 {})", num(0)?),
|
||||
("rotate", 1) => format!("rotate({})", ang(0)?),
|
||||
("skewx", 1) => format!("skewX({})", ang(0)?),
|
||||
("skewy", 1) => format!("skewY({})", ang(0)?),
|
||||
("skew", 1) => format!("skewX({})", ang(0)?),
|
||||
("skew", 2) => format!("matrix(1 {} {} 1 0 0)", ang(1)?.to_radians().tan(), ang(0)?.to_radians().tan()),
|
||||
_ => return None,
|
||||
};
|
||||
out.push(f);
|
||||
rest = rest.get(close + 1..)?;
|
||||
}
|
||||
let svg = out.join(" ");
|
||||
(!out.is_empty() && svg != v).then_some(svg)
|
||||
}
|
||||
|
||||
/// A length in user units: `px` or unitless.
|
||||
fn length(a: &str) -> Option<f64> {
|
||||
let n = a.strip_suffix("px").unwrap_or(a).parse::<f64>().ok()?;
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
/// An angle in degrees.
|
||||
fn angle(a: &str) -> Option<f64> {
|
||||
let a = a.to_ascii_lowercase();
|
||||
let (n, k) = if let Some(n) = a.strip_suffix("deg") {
|
||||
(n, 1.0)
|
||||
} else if let Some(n) = a.strip_suffix("grad") {
|
||||
(n, 0.9)
|
||||
} else if let Some(n) = a.strip_suffix("rad") {
|
||||
(n, 180.0 / PI)
|
||||
} else if let Some(n) = a.strip_suffix("turn") {
|
||||
(n, 360.0)
|
||||
} else {
|
||||
(a.as_str(), 1.0)
|
||||
};
|
||||
let d = n.parse::<f64>().ok()? * k;
|
||||
d.is_finite().then_some(d)
|
||||
}
|
||||
|
||||
/// Where CSS can be in `svg`: `style` attribute values and `<style>` contents.
|
||||
fn css_ranges(xml: &roxmltree::Document) -> Vec<Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
for n in xml.descendants().filter(XNode::is_element) {
|
||||
if let Some(a) = n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) {
|
||||
out.push(a.range_value());
|
||||
}
|
||||
if n.tag_name().name() == "style" {
|
||||
out.extend(n.children().filter(XNode::is_text).map(|t| t.range()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `transform` declarations in CSS in `svg`, each as (value range, value in SVG syntax).
|
||||
pub(super) fn rewrites(svg: &str, xml: &roxmltree::Document) -> Vec<(Range<usize>, String)> {
|
||||
let mut out = Vec::new();
|
||||
for r in css_ranges(xml) {
|
||||
let Some(css) = svg.get(r.clone()) else { continue };
|
||||
let mut from = 0;
|
||||
while let Some(i) = css.get(from..).and_then(|s| s.find("transform")).map(|i| i + from) {
|
||||
from = i + "transform".len();
|
||||
let before = css.get(..i).and_then(|s| s.chars().next_back());
|
||||
if before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
continue;
|
||||
}
|
||||
let after = css.get(from..).unwrap_or("");
|
||||
let Some(colon) = after.find(|c: char| !c.is_whitespace()).filter(|&k| after.get(k..).is_some_and(|s| s.starts_with(':'))) else {
|
||||
continue;
|
||||
};
|
||||
let start = from + colon + 1;
|
||||
let len = css.get(start..).and_then(|s| s.find([';', '}', '!', '"', '\'', '<'])).unwrap_or(css.len() - start);
|
||||
let Some(value) = css.get(start..start + len) else { continue };
|
||||
if let Some(svg_value) = to_svg(value) {
|
||||
out.push((r.start + start..r.start + start + len, format!(" {svg_value}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn css_transforms_become_svg() {
|
||||
assert_eq!(to_svg("translate(100px, 100px)").as_deref(), Some("translate(100 100)"));
|
||||
assert_eq!(to_svg(" translateX(5px) rotate(0.25turn) ").as_deref(), Some("translate(5 0) rotate(90)"));
|
||||
assert_eq!(to_svg("rotate(1.5707963267948966rad) scaleY(2)").as_deref(), Some("rotate(90) scale(1 2)"));
|
||||
assert_eq!(to_svg("skew(45deg)").as_deref(), Some("skewX(45)"));
|
||||
assert_eq!(to_svg("matrix(1, 0, 0, 1, 10px, 20px)").as_deref(), Some("matrix(1 0 0 1 10 20)"));
|
||||
assert_eq!(to_svg("none").as_deref(), Some("matrix(1 0 0 1 0 0)"));
|
||||
// Already SVG, or not expressible: left alone.
|
||||
assert_eq!(to_svg("translate(10 20)"), None);
|
||||
assert_eq!(to_svg("translate(50%, 0)"), None);
|
||||
assert_eq!(to_svg("rotate3d(1, 0, 0, 45deg)"), None);
|
||||
assert_eq!(to_svg("translate(1px"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_css_is_rewritten() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><style>.a { fill: red; transform: rotate(45deg) } .b{-webkit-transform:rotate(1deg)}</style><rect class="a" transform="translate(1 2)" style="transform:translate(3px,4px);fill:blue"/><text>transform: rotate(9deg)</text></svg>"#;
|
||||
let xml = roxmltree::Document::parse(svg).unwrap();
|
||||
let found = rewrites(svg, &xml);
|
||||
let r: Vec<(&str, &str)> = found.iter().map(|(r, v)| (&svg[r.clone()], v.as_str())).collect();
|
||||
assert_eq!(r, vec![(" rotate(45deg) ", " rotate(45)"), ("translate(3px,4px)", " translate(3 4)")]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Percentage lengths inside a `<symbol>` with a `viewBox`, as numbers, for usvg.
|
||||
//!
|
||||
//! A percentage is of the nearest viewport's size in user units, and a symbol's `viewBox` sets
|
||||
//! that size for its content as a nested `<svg>`'s does (SVG 2, 8.9 Units; 5.6 The `use` element).
|
||||
//! usvg takes the percentages of the `<use>`'s width and height instead and then scales the art by
|
||||
//! the `viewBox`, so it comes out too large and out of place (#1561). Before usvg runs, the
|
||||
//! percentages of the shapes' geometry are rewritten in the `viewBox`'s units.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::str::FromStr;
|
||||
|
||||
use usvg::roxmltree;
|
||||
|
||||
/// The elements whose geometry attributes are rewritten.
|
||||
const SHAPES: &[&str] = &["rect", "circle", "ellipse", "line", "image", "use", "text"];
|
||||
/// The elements whose content has lengths of its own kind (or a viewport of its own).
|
||||
const SCOPES: &[&str] = &["symbol", "svg", "pattern", "mask", "clipPath", "marker"];
|
||||
|
||||
/// What a percentage of attribute `name` is of, in a viewport of `w` × `h`: its width, its height,
|
||||
/// or (a radius) its diagonal over √2.
|
||||
fn basis(name: &str, w: f64, h: f64) -> Option<f64> {
|
||||
match name {
|
||||
"x" | "x1" | "x2" | "cx" | "width" | "rx" => Some(w),
|
||||
"y" | "y1" | "y2" | "cy" | "height" | "ry" => Some(h),
|
||||
"r" => Some(((w * w + h * h) / 2.0).sqrt()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The percentage geometry of shapes in symbols with a `viewBox`, each as (value range, number).
|
||||
pub(super) fn rewrites(xml: &roxmltree::Document) -> Vec<(Range<usize>, String)> {
|
||||
let mut out = Vec::new();
|
||||
for n in xml.descendants().filter(|n| n.is_element() && SHAPES.contains(&n.tag_name().name())) {
|
||||
let Some(scope) = n.ancestors().skip(1).find(|a| SCOPES.contains(&a.tag_name().name())) else { continue };
|
||||
let Some(vb) = Some(scope)
|
||||
.filter(|s| s.tag_name().name() == "symbol")
|
||||
.and_then(|s| s.attribute("viewBox"))
|
||||
.and_then(|v| svgtypes::ViewBox::from_str(v).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !(vb.w > 0.0 && vb.h > 0.0 && vb.w.is_finite() && vb.h.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
for a in n.attributes().filter(|a| a.namespace().is_none()) {
|
||||
let percent = a.value().trim().strip_suffix('%').and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
if let (Some(p), Some(of)) = (percent, basis(a.name(), vb.w, vb.h)) {
|
||||
out.push((a.range_value(), (p / 100.0 * of).to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percentages_in_a_symbol_are_of_its_view_box() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><rect width="50%"/>
|
||||
<symbol id="a" viewBox="0 0 200 100"><rect x="10%" y="10%" width="100%" height="50%" rx="5"/><circle cx="50%" r="100%"/>
|
||||
<linearGradient x2="100%"/><pattern><rect width="100%"/></pattern><g><line x2="25%" y2=" 25% "/></g></symbol>
|
||||
<symbol id="b"><rect width="100%"/></symbol><use href="#a" width="30%"/></svg>"##;
|
||||
let xml = roxmltree::Document::parse(svg).unwrap();
|
||||
let found: Vec<(&str, String)> = rewrites(&xml).into_iter().map(|(r, v)| (&svg[r], v)).collect();
|
||||
let r = (200.0f64 * 200.0 / 2.0 + 100.0 * 100.0 / 2.0).sqrt().to_string();
|
||||
let expected =
|
||||
[("10%", "20"), ("10%", "10"), ("100%", "200"), ("50%", "50"), ("50%", "100"), ("100%", r.as_str()), ("25%", "50"), (" 25% ", "25")];
|
||||
assert_eq!(found.iter().map(|(a, b)| (*a, b.as_str())).collect::<Vec<_>>(), expected);
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,8 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war
|
||||
let cx = Ctx::new(xml, dpi);
|
||||
let mut out = String::new();
|
||||
let mut copied = 0;
|
||||
for t in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text" && n.tag_name().namespace() == Some(SVG_NS)) {
|
||||
let is_text = |n: &XNode| n.is_element() && matches!(n.tag_name().name(), "text" | "flowRoot") && n.tag_name().namespace() == Some(SVG_NS);
|
||||
for t in xml.descendants().filter(is_text) {
|
||||
let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
|
||||
if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") {
|
||||
warnings.push(format!("{label} in a clip path ignored"));
|
||||
@@ -191,11 +192,13 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(q) = qname.strip_suffix("text").filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
|
||||
let flowed = t.tag_name().name() == "flowRoot";
|
||||
let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
|
||||
if cx.css.own(t, "display").as_deref() == Some("none") || r.start < copied {
|
||||
continue;
|
||||
}
|
||||
let Some((text, paints)) = cx.build(xml, t, &label, warnings) else { continue };
|
||||
let built = if flowed { cx.build_flow(t, &label, warnings) } else { cx.build(xml, t, &label, warnings) };
|
||||
let Some((text, paints)) = built else { continue };
|
||||
out.push_str(&svg[copied..r.start]);
|
||||
placeholder(&mut out, q, &cx.css, t, &slots.prefix, slots.texts.len(), text.marker, &paints);
|
||||
copied = r.end;
|
||||
@@ -468,10 +471,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 +487,123 @@ 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 Inkscape `<flowRoot>` (SVG 1.2 flowed text, which browsers don't draw but
|
||||
/// Inkscape writes) as area type: its frame the first `<rect>` of its `<flowRegion>`, a
|
||||
/// paragraph per `<flowPara>`, styled as `<text>` is. Other regions are left out with a warning.
|
||||
fn build_flow(&self, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
|
||||
let (vw, vh) = self.viewport;
|
||||
let region = t.children().find(|c| c.is_element() && c.tag_name().name() == "flowRegion");
|
||||
let rect = region.and_then(|r| r.children().find(|c| c.is_element() && c.tag_name().name() == "rect"));
|
||||
let size = |n: XNode, a: &str, pct: f64| n.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct));
|
||||
let frame = rect.and_then(|r| {
|
||||
let (w, h) = (size(r, "width", vw)?, size(r, "height", vh)?);
|
||||
(w > 0.0 && h > 0.0).then(|| (r, Rect::new(0.0, 0.0, w, h)))
|
||||
});
|
||||
let Some((r, frame)) = frame else {
|
||||
warnings.push(format!("{label}: flowed text (flowRoot) without a rectangle region was left out"));
|
||||
return None;
|
||||
};
|
||||
let origin = Affine::translate((size(r, "x", vw).unwrap_or(0.0), size(r, "y", vh).unwrap_or(0.0)));
|
||||
let paras: Vec<XNode> = t.descendants().filter(|n| n.is_element() && n.tag_name().name() == "flowPara").collect();
|
||||
let collapse = |n: XNode| {
|
||||
n.descendants().filter(XNode::is_text).filter_map(|c| c.text()).collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
};
|
||||
let text = paras.iter().map(|p| collapse(*p)).collect::<Vec<_>>().join("\n");
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let style = self.elem(paras.first().copied().unwrap_or(t)).style.clone();
|
||||
let mut obj = TextObject::point(Point::ZERO, &text, style);
|
||||
obj.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(frame) };
|
||||
obj.xf = parse_transform(r.attribute("transform")) * origin;
|
||||
let servers = vec![(None, None); obj.runs.len()];
|
||||
let b = obj.xf.transform_rect_bbox(frame);
|
||||
// A size, place or transform past what numbers hold (`width="1e999"`) has no frame to set type in.
|
||||
if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) {
|
||||
warnings.push(format!("{label}: flowed text (flowRoot) with a region too large to place was left out"));
|
||||
return None;
|
||||
}
|
||||
let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0));
|
||||
let name = t.attribute("id").unwrap_or("").to_string();
|
||||
Some((PendingText { name, obj, servers, paints: 0, non_scaling: false, under: None, marker }, Vec::new()))
|
||||
}
|
||||
|
||||
/// 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");
|
||||
|
||||
@@ -181,3 +181,38 @@ fn reflect_and_repeat_match_resvg() {
|
||||
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1557: `transform` as a CSS property, in CSS syntax, moves the object (and wins over the
|
||||
/// attribute, as in CSS), from a `style` attribute or a `<style>` rule.
|
||||
#[test]
|
||||
fn css_transforms_move_objects() {
|
||||
let d = import_with(
|
||||
&svg(r#"<style>.r { transform: rotate(90deg) }</style>
|
||||
<rect id="a" x="0" y="0" width="50" height="50" fill="red" style="transform: translate(100px, 100px)"/>
|
||||
<rect id="b" x="0" y="0" width="50" height="50" fill="blue" transform="translate(0 100)" style="transform: translateX(100px)"/>
|
||||
<rect id="c" class="r" x="0" y="0" width="50" height="20" fill="green"/>"#),
|
||||
&ImportOptions::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let b: Vec<Rect> = art(&d).iter().map(|n| n.geometric_bounds().unwrap()).collect();
|
||||
let near =
|
||||
|r: Rect, x0: f64, y0: f64, x1: f64, y1: f64| [(r.x0, x0), (r.y0, y0), (r.x1, x1), (r.y1, y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6);
|
||||
assert!(near(b[0], 100.0, 100.0, 150.0, 150.0), "{:?}", b[0]);
|
||||
assert!(near(b[1], 100.0, 0.0, 150.0, 50.0), "{:?}", b[1]);
|
||||
assert!(near(b[2], -20.0, 0.0, 0.0, 50.0), "{:?}", b[2]);
|
||||
}
|
||||
|
||||
/// #1561: percentages in a `<symbol>` with a `viewBox` are of the `viewBox`, so the symbol draws as
|
||||
/// the same one written with numbers.
|
||||
#[test]
|
||||
fn percentages_in_a_symbol_are_of_its_view_box() {
|
||||
let drawing = |w: &str, h: &str, cx: &str, cy: &str| {
|
||||
let s = svg(&format!(
|
||||
r##"<defs><symbol id="s" viewBox="0 0 100 50"><rect width="{w}" height="{h}" fill="red"/><circle cx="{cx}" cy="{cy}" r="20" fill="blue"/></symbol></defs>
|
||||
<use xlink:href="#s" x="20" y="10" width="160" height="100"/>"##
|
||||
));
|
||||
render_artboard(&import_with(&s, &ImportOptions::default()).unwrap().0)
|
||||
};
|
||||
assert_similar(&drawing("100%", "100%", "25%", "50%"), &drawing("100", "50", "25", "25"), 24.0, 0.001);
|
||||
}
|
||||
@@ -518,3 +518,114 @@ 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:?}");
|
||||
}
|
||||
|
||||
/// #1557: the `font` shorthand sets the type's size, weight and style.
|
||||
#[test]
|
||||
fn the_font_shorthand_styles_type() {
|
||||
let d = open(r#"<text x="10" y="40" style="font: italic bold 30px sans-serif">Deco</text>"#);
|
||||
let t = only_text(&d);
|
||||
let st = &t.runs[0].style;
|
||||
assert!(close(st.size * t.xf.determinant().abs().sqrt(), 30.0, 1e-6), "{}", st.size);
|
||||
let style = st.font_style.to_ascii_lowercase();
|
||||
assert!(style.contains("bold") && style.contains("italic"), "{style}");
|
||||
}
|
||||
|
||||
/// Inkscape's flowed text (`<flowRoot>`) becomes area type in its region's rectangle, a paragraph
|
||||
/// per `<flowPara>`; another kind of region is left out with a warning, not silently.
|
||||
#[test]
|
||||
fn flowed_text_becomes_area_type() {
|
||||
let (d, w) = import_with_report(&svg(
|
||||
r#"<flowRoot style="font-size:16px;font-family:sans-serif"><flowRegion><rect x="20" y="30" width="100" height="160"/></flowRegion><flowPara>This is flowed text</flowPara><flowPara>Second <flowSpan>one</flowSpan></flowPara></flowRoot>
|
||||
<flowRoot><flowRegion><path d="M0 0 L10 0 L5 10 Z"/></flowRegion><flowPara>Lost</flowPara></flowRoot>"#,
|
||||
))
|
||||
.unwrap();
|
||||
let t = only_text(&d);
|
||||
assert_eq!(t.plain_text(), "This is flowed text\nSecond one");
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
let b = t.xf.transform_rect_bbox(frame.bounds().unwrap());
|
||||
assert!(close(b.x0, 20.0, 1e-6) && close(b.y0, 30.0, 1e-6) && close(b.x1, 120.0, 1e-6) && close(b.y1, 190.0, 1e-6), "{b:?}");
|
||||
assert!(close(t.runs[0].style.size * t.xf.determinant().abs().sqrt(), 16.0, 1e-6));
|
||||
assert!(w.iter().any(|w| w.contains("flowRoot")), "{w:?}");
|
||||
}
|
||||
|
||||
/// Flowed text that can't be read is left out with a warning each, and never stops the import: no
|
||||
/// region, a region too large for numbers, a flowRoot inside a flowRoot (read once, as the outer one).
|
||||
#[test]
|
||||
fn flowed_text_that_cannot_be_read_is_reported() {
|
||||
let (d, w) = import_with_report(&svg(
|
||||
r#"<flowRoot id="none"><flowPara>No region</flowPara></flowRoot>
|
||||
<flowRoot id="huge"><flowRegion><rect width="1e999" height="10"/></flowRegion><flowPara>Huge</flowPara></flowRoot>
|
||||
<flowRoot id="far"><flowRegion><rect x="1e308" width="1e308" height="10" transform="scale(1e308)"/></flowRegion><flowPara>Far</flowPara></flowRoot>
|
||||
<flowRoot id="outer"><flowRegion><rect width="50" height="50"/></flowRegion><flowPara>Outer</flowPara><flowRoot><flowRegion><rect width="5" height="5"/></flowRegion><flowPara>inner</flowPara></flowRoot></flowRoot>"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(only_text(&d).plain_text(), "Outer\ninner");
|
||||
for id in ["none", "huge", "far"] {
|
||||
assert!(w.iter().any(|w| w.contains(id) && w.contains("flowRoot")), "{id}: {w:?}");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,33 @@ fn doc() -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
/// A large palette should create one class per distinct declaration, preserving first-use
|
||||
/// numbers even when earlier styles recur after hundreds of others.
|
||||
#[test]
|
||||
fn many_css_styles_preserve_first_use_numbers() {
|
||||
let mut d = Document::new(300.0, 10.0);
|
||||
let layer = d.layers[0].id;
|
||||
let colors = (0u16..256).chain([0, 128, 255]);
|
||||
for (x, color) in colors.enumerate() {
|
||||
let x = x as f64;
|
||||
let appearance = Appearance::basic(Paint::solid(Color::rgb8(color as u8, 31, 63)), Paint::None, 0.0);
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 0.5, 1.0)), appearance);
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
for (styling, definition_marker) in [(Styling::InternalCss, ".cls-"), (Styling::StyleEntities, "<!ENTITY st")] {
|
||||
let svg = export(&d, &ExportOptions { styling, ..Default::default() });
|
||||
let paths: Vec<_> = svg.lines().filter(|line| line.contains("<path ")).collect();
|
||||
assert_eq!(paths.len(), 259, "{styling:?}");
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
let number = if i < 256 { i + 1 } else { [1, 129, 256][i - 256] };
|
||||
let expected = if styling == Styling::InternalCss { format!("class=\"cls-{number}\"") } else { format!("style=\"&st{number};\"") };
|
||||
assert!(path.contains(&expected), "path {i} should reference {expected}: {path}");
|
||||
}
|
||||
assert_eq!(svg.matches(definition_marker).count(), 256, "{styling:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_combination_reads_back() {
|
||||
let d = doc();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -384,6 +384,12 @@ impl Targets {
|
||||
self
|
||||
}
|
||||
|
||||
/// The ruler guides alone ([`Self::with_rulers`]): what a dragged selection's grabbed point
|
||||
/// still snaps to with View → Snap to Point when Smart Guides line the selection up.
|
||||
pub(crate) fn rulers(cx: &ToolContext) -> Self {
|
||||
Self::default().styled(cx).with_rulers(cx)
|
||||
}
|
||||
|
||||
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
|
||||
/// Guides off: the anchors of the art but `exclude` and the ruler guides.
|
||||
pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option<Self> {
|
||||
|
||||
@@ -90,6 +90,9 @@ pub(crate) struct MoveSnap {
|
||||
/// original.
|
||||
copy_targets: Option<Targets>,
|
||||
copy_points: Option<Targets>,
|
||||
/// With Smart Guides on, the ruler guides View › Snap to Point still pulls the grabbed point
|
||||
/// onto (#1534); `points` holds them with Smart Guides off.
|
||||
rulers: Option<Targets>,
|
||||
}
|
||||
|
||||
impl MoveSnap {
|
||||
@@ -102,6 +105,7 @@ impl MoveSnap {
|
||||
points: points(&cx.selection.objects),
|
||||
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
|
||||
copy_points: points(&[]),
|
||||
rulers: (guides && cx.snap_to_point).then(|| Targets::rulers(cx)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +137,14 @@ impl MoveSnap {
|
||||
guides = ov;
|
||||
}
|
||||
}
|
||||
// A ruler guide within reach of the grabbed point wins over lining the bounds up.
|
||||
if let Some(t) = &self.rulers {
|
||||
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
|
||||
if q != start + d {
|
||||
d = q - start;
|
||||
guides = ov;
|
||||
}
|
||||
}
|
||||
if cx.snap_to_pixel
|
||||
&& let Some(b) = self.bounds
|
||||
{
|
||||
@@ -733,6 +745,29 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps
|
||||
/// to a ruler guide within the Snap to Point distance; not with Snap to Point off.
|
||||
#[test]
|
||||
fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)];
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let dx = |c: &ToolContext, to: f64| {
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
let a = t.pointer(c, &ev(PointerKind::Drag, to, 150.0));
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
v["matrix"][4].as_f64().unwrap()
|
||||
};
|
||||
let c = cx(&d, &s, &p);
|
||||
assert!(c.smart_guides && c.snap_to_point);
|
||||
assert!((dx(&c, 172.0) - 23.4).abs() < 1e-9, "onto the guide");
|
||||
assert_eq!(dx(&c, 165.0), 15.0, "out of reach");
|
||||
assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0);
|
||||
}
|
||||
|
||||
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
|
||||
/// gathered before the previews moved it); released, the move doesn't.
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -12,14 +12,23 @@
|
||||
//! merged into one point) become polylines through the pixel centres; rings without ends
|
||||
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
|
||||
//! on either side of a junction left with two of them are joined.
|
||||
//! 5. **Curves**: each polyline is fitted like an outline ([`fit::fit_polyline`]); the stroke
|
||||
//! width is the line's mean width (its pixel count over its length).
|
||||
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
|
||||
//! of the line's ink, read from the image: across the line (along the pixel column or row most
|
||||
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
|
||||
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
|
||||
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
|
||||
//! median of those widths, and each end is put where the ink ends, less the half width a round
|
||||
//! cap adds.
|
||||
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
|
||||
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::fit::{self, FitOptions};
|
||||
use crate::Raster;
|
||||
use crate::fit;
|
||||
use crate::logo::Style;
|
||||
|
||||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||||
pub(crate) struct Line {
|
||||
@@ -31,7 +40,19 @@ pub(crate) struct Line {
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||||
pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f64, o: &FitOptions) -> (Vec<bool>, Vec<Line>) {
|
||||
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
|
||||
/// image's own black), which the lines' middles and widths are read against.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn lines(
|
||||
labels: &[u16],
|
||||
label: u16,
|
||||
w: usize,
|
||||
h: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
) -> (Vec<bool>, Vec<Line>) {
|
||||
let mut thin = vec![false; labels.len()];
|
||||
let mut out = vec![];
|
||||
if w == 0 || h == 0 || labels.len() != w * h {
|
||||
@@ -61,7 +82,7 @@ pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(&comp, w, max_width, o) else { continue };
|
||||
let Some(line) = trace_line(&comp, w, max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
thin[i] = true;
|
||||
}
|
||||
@@ -221,7 +242,7 @@ impl Run {
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Option<Line> {
|
||||
fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
let xs = comp.iter().map(|&i| i % w);
|
||||
let ys = comp.iter().map(|&i| i / w);
|
||||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||||
@@ -312,14 +333,168 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Optio
|
||||
};
|
||||
let spur = width(&runs);
|
||||
let runs = join(prune(runs, centres.len(), spur), centres.len());
|
||||
let width = width(&runs);
|
||||
let subs: Vec<_> = runs.iter().filter_map(|r| fit::fit_polyline(&r.pts, r.closed, o)).collect();
|
||||
let mut runs = runs;
|
||||
let coarse = width(&runs);
|
||||
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
|
||||
// How many runs end at each node: one is an end of the line.
|
||||
let mut meet = vec![0usize; centres.len()];
|
||||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||||
meet[*e] += 1;
|
||||
}
|
||||
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
|
||||
for r in &mut runs {
|
||||
let n = r.pts.len();
|
||||
let old = r.pts.clone();
|
||||
for i in 0..n {
|
||||
// A junction stays where its pixels put it.
|
||||
let end = if i == 0 {
|
||||
r.ends[0]
|
||||
} else if i + 1 == n {
|
||||
r.ends[1]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if end.is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = if r.closed { (old[(i + n - 2 % n) % n], old[(i + 2) % n]) } else { (old[i.saturating_sub(2)], old[(i + 2).min(n - 1)]) };
|
||||
let d = b - a;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let upright = d.x.abs() >= d.y.abs();
|
||||
let Some(across) = ink.across(old[i], upright) else { continue };
|
||||
r.pts[i] = old[i] + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
|
||||
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
|
||||
read += 1;
|
||||
soft += usize::from(across.soft);
|
||||
}
|
||||
}
|
||||
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
|
||||
// wide as its pixels make it).
|
||||
widths.sort_by(f64::total_cmp);
|
||||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||||
// Each end goes where the ink ends, less the half width its round cap adds.
|
||||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||||
for k in 0..2 {
|
||||
if r.ends[k].is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let n = r.pts.len();
|
||||
let (tip, inner) = if k == 0 { (r.pts[0], r.pts[3.min(n - 1)]) } else { (r.pts[n - 1], r.pts[n - 1 - 3.min(n - 1)]) };
|
||||
let d = tip - inner;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let more = ink.beyond(tip, d / d.hypot()) - width / 2.0;
|
||||
if more.abs() > 0.05 && more > -1.0 {
|
||||
let to = tip + d / d.hypot() * more;
|
||||
if k == 0 { r.pts[0] = to } else { r.pts[n - 1] = to }
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut style = *style;
|
||||
if read > 0 && soft * 4 < read {
|
||||
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 2.5;
|
||||
style.sigma = 1.5;
|
||||
}
|
||||
let subs: Vec<_> = runs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
|
||||
})
|
||||
.collect();
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
struct Ink<'a> {
|
||||
img: &'a Raster,
|
||||
/// The line's colour.
|
||||
color: [f64; 3],
|
||||
/// How far (pixels) from the skeleton the ink is looked for.
|
||||
reach: i64,
|
||||
}
|
||||
|
||||
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
|
||||
/// whether its edges are soft (some pixel is part ink, part paper).
|
||||
struct Across {
|
||||
middle: f64,
|
||||
width: f64,
|
||||
soft: bool,
|
||||
}
|
||||
|
||||
impl Ink<'_> {
|
||||
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
|
||||
fn far(&self, x: i64, y: i64) -> Option<f64> {
|
||||
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
|
||||
if x < 0 || y < 0 || x >= w || y >= h {
|
||||
return None;
|
||||
}
|
||||
let at = (y * w + x) as usize * 4;
|
||||
let p = self.img.rgba.get(at..at + 3)?;
|
||||
Some((0..3).map(|k| (f64::from(p[k]) - self.color[k]).powi(2)).sum::<f64>().sqrt())
|
||||
}
|
||||
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
|
||||
/// within reach, along its row and its column.
|
||||
fn paper(&self, x: i64, y: i64) -> f64 {
|
||||
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
|
||||
}
|
||||
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
|
||||
fn across(&self, p: Point, upright: bool) -> Option<Across> {
|
||||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||||
let paper = self.paper(x, y);
|
||||
// Paper too like the ink says nothing.
|
||||
if paper < 24.0 {
|
||||
return None;
|
||||
}
|
||||
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
|
||||
// off the line's darkest row) out to where the ink ends, or rises again (another line).
|
||||
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
|
||||
let here = tone(peak);
|
||||
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
|
||||
for side in [-1, 1] {
|
||||
let mut last = here;
|
||||
for k in 1..=self.reach {
|
||||
let t = tone(peak + side * k);
|
||||
if t < 0.04 || t > last + 0.1 {
|
||||
break;
|
||||
}
|
||||
sum += t;
|
||||
moment += t * (peak + side * k) as f64;
|
||||
soft |= t > 0.1 && t < 0.9;
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
|
||||
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
|
||||
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
|
||||
}
|
||||
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
|
||||
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
|
||||
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
|
||||
if paper < 24.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for step in 1..=4 * (self.reach + 2) {
|
||||
let q = tip + dir * (step as f64 * 0.25);
|
||||
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
if t < 0.04 {
|
||||
break;
|
||||
}
|
||||
sum += 0.25 * t;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
|
||||
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
|
||||
/// to a junction that keeps at least two other runs.
|
||||
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+87
-87
@@ -1,20 +1,11 @@
|
||||
//! Loop → Bézier curves, and centre line → Bézier curves.
|
||||
//! Loop → Bézier curves, and the pieces of that which centre lines use too.
|
||||
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, CubicBez, ParamCurve, PathData, Point, SubPath};
|
||||
use vectorcraft_pathops::{SimplifyOptions, simplify_with};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath};
|
||||
|
||||
use crate::contour::Loop;
|
||||
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)]
|
||||
pub(crate) struct FitOptions {
|
||||
pub polygon_tol: f64,
|
||||
pub fit_tol: f64,
|
||||
pub corner_angle: f64,
|
||||
pub snap_lines: bool,
|
||||
}
|
||||
|
||||
fn seg_dist(p: Point, a: Point, b: Point) -> f64 {
|
||||
let ab = b - a;
|
||||
let l2 = ab.hypot2();
|
||||
@@ -60,10 +51,69 @@ fn before(a: Point, b: Point) -> std::cmp::Ordering {
|
||||
a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
|
||||
}
|
||||
|
||||
/// Lines within this of level or upright are made exactly so (the tangent of 2.5°).
|
||||
const NEARLY_UPRIGHT: f64 = 0.0437;
|
||||
|
||||
/// Snap Curves To Lines: the straight pieces of `segs` (a chain; `closed` when its end joins its
|
||||
/// start) that are within 2.5° of level or upright and at least 2 px long are made exactly level
|
||||
/// or upright, by moving their ends across them: both to their mean, or, in an open chain, the
|
||||
/// free end to the end that is fixed (with `fixed_ends`, a chain's own ends stay where they are:
|
||||
/// they are junctions other regions share). The pieces next to a moved end follow it.
|
||||
fn snap_upright(segs: &mut [Cubic], closed: bool, fixed_ends: bool) {
|
||||
let n = segs.len();
|
||||
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
|
||||
let lines: Vec<bool> = segs.iter().map(straight).collect();
|
||||
// Anchor `i` starts piece `i`; an open chain has one more, at its end.
|
||||
let fixed = |i: usize| !closed && fixed_ends && (i == 0 || i == n);
|
||||
let at = |segs: &[Cubic], i: usize| if i < n { segs[i][0] } else { segs[n - 1][3] };
|
||||
let put = |segs: &mut [Cubic], i: usize, p: Point| {
|
||||
let i = if closed { i % n } else { i };
|
||||
let d = p - at(segs, i);
|
||||
if i < n {
|
||||
segs[i][0] = p;
|
||||
segs[i][1] += d;
|
||||
}
|
||||
if let Some(before) = if i > 0 { Some(i - 1) } else { closed.then_some(n - 1) } {
|
||||
segs[before][3] = p;
|
||||
segs[before][2] += d;
|
||||
}
|
||||
};
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
let (ia, ib) = (i, if closed { (i + 1) % n } else { i + 1 });
|
||||
let (a, b) = (at(segs, ia), at(segs, ib));
|
||||
let d = b - a;
|
||||
if !line || d.hypot() < 2.0 || ia == ib {
|
||||
continue;
|
||||
}
|
||||
let level = d.y.abs() <= NEARLY_UPRIGHT * d.x.abs();
|
||||
if !level && d.x.abs() > NEARLY_UPRIGHT * d.y.abs() {
|
||||
continue;
|
||||
}
|
||||
let pick = |p: Point| if level { p.y } else { p.x };
|
||||
let to = match (fixed(ia), fixed(ib)) {
|
||||
(true, true) => continue,
|
||||
(true, false) => pick(a),
|
||||
(false, true) => pick(b),
|
||||
(false, false) => (pick(a) + pick(b)) / 2.0,
|
||||
};
|
||||
let moved = |p: Point| if level { Point::new(p.x, to) } else { Point::new(to, p.y) };
|
||||
if !fixed(ia) {
|
||||
put(segs, ia, moved(a));
|
||||
}
|
||||
if !fixed(ib) {
|
||||
put(segs, ib, moved(b));
|
||||
}
|
||||
}
|
||||
// A straight piece has its handles on it.
|
||||
for (c, _) in segs.iter_mut().zip(&lines).filter(|(_, line)| **line) {
|
||||
*c = [c[0], c[0] + (c[3] - c[0]) / 3.0, c[0] + (c[3] - c[0]) * (2.0 / 3.0), c[3]];
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
pub(crate) 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;
|
||||
@@ -80,13 +130,17 @@ fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
|
||||
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);
|
||||
}
|
||||
if style.upright {
|
||||
snap_upright(&mut segs, true, false);
|
||||
}
|
||||
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> {
|
||||
/// edge two regions share is one curve. `fixed_ends`: its ends are junctions and stay put (a centre
|
||||
/// line's ends are its own).
|
||||
pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec<Cubic> {
|
||||
let n = pts.len();
|
||||
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return vec![] };
|
||||
let flip = match before(first, last) {
|
||||
@@ -107,6 +161,9 @@ fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
|
||||
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 style.upright {
|
||||
snap_upright(&mut segs, false, fixed_ends);
|
||||
}
|
||||
if flip { reversed(segs) } else { segs }
|
||||
}
|
||||
|
||||
@@ -137,22 +194,30 @@ pub(crate) fn fit_loop(
|
||||
if piece.closed {
|
||||
segs = fit_closed(&piece.pts, &style)?;
|
||||
} else {
|
||||
segs.extend(fit_piece(&piece.pts, &style));
|
||||
segs.extend(fit_piece(&piece.pts, &style, true));
|
||||
}
|
||||
}
|
||||
subpath(&segs, true)
|
||||
}
|
||||
|
||||
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
|
||||
/// no handles; an anchor whose two handles line up is smooth.
|
||||
pub(crate) fn subpath(segs: &[Cubic], closed: bool) -> Option<SubPath> {
|
||||
let n = segs.len();
|
||||
// A straight piece has no handles.
|
||||
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
|
||||
let mut anchors = Vec::with_capacity(n);
|
||||
for (i, cur) in segs.iter().enumerate() {
|
||||
let before = segs.get((i + n - 1) % n)?;
|
||||
let p = cur[0];
|
||||
let (h_in, h_out) = (if straight(before) { p } else { before[2] }, if straight(cur) { p } else { cur[1] });
|
||||
let mut anchors = Vec::with_capacity(n + 1);
|
||||
for i in 0..if closed { n } else { n + 1 } {
|
||||
// The piece that arrives at this anchor and the one that leaves it.
|
||||
let before = if i > 0 { segs.get(i - 1) } else { closed.then(|| segs.last()).flatten() };
|
||||
let after = segs.get(i);
|
||||
let p = after.map(|c| c[0]).or(before.map(|c| c[3]))?;
|
||||
let h_in = before.filter(|c| !straight(c)).map_or(p, |c| c[2]);
|
||||
let h_out = after.filter(|c| !straight(c)).map_or(p, |c| c[1]);
|
||||
let (vi, vo) = (p - h_in, h_out - p);
|
||||
let smooth = vi.hypot() > 1e-9 && vo.hypot() > 1e-9 && vi.cross(vo).abs() < 0.07 * vi.hypot() * vo.hypot() && vi.dot(vo) > 0.0;
|
||||
anchors.push(Anchor { p, h_in, h_out, kind: if smooth { AnchorKind::Smooth } else { AnchorKind::Corner } });
|
||||
}
|
||||
(n >= 2).then(|| SubPath::new(anchors, true))
|
||||
(n >= if closed { 2 } else { 1 }).then(|| SubPath::new(anchors, closed))
|
||||
}
|
||||
|
||||
/// The closed outline `pts` as straight pieces within `tol` of it (Douglas–Peucker).
|
||||
@@ -170,68 +235,3 @@ fn polygon(pts: &[Point], tol: f64) -> Vec<Cubic> {
|
||||
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
(0..kept.len()).map(|i| [kept[i], kept[i], kept[(i + 1) % kept.len()], kept[(i + 1) % kept.len()]]).collect()
|
||||
}
|
||||
|
||||
/// Fit a centre line through `pts` (pixel-centre points in order) into a Bézier subpath: the
|
||||
/// points within `polygon_tol` of it are dropped (Douglas–Peucker), then curves are fitted. A
|
||||
/// `closed` line runs back to its first point.
|
||||
pub(crate) fn fit_polyline(pts: &[Point], closed: bool, o: &FitOptions) -> Option<SubPath> {
|
||||
let n = pts.len();
|
||||
let first = *pts.first()?;
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut ext = pts.to_vec();
|
||||
let mut keep = vec![false; n + 1];
|
||||
keep[0] = true;
|
||||
if closed {
|
||||
// Split the ring at its first point and the point farthest from it.
|
||||
ext.push(first);
|
||||
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(first).total_cmp(&pts[j].distance_squared(first))).unwrap_or(n / 2);
|
||||
keep[far] = true;
|
||||
rdp(&ext, 0, far, o.polygon_tol, &mut keep);
|
||||
rdp(&ext, far, n, o.polygon_tol, &mut keep);
|
||||
} else {
|
||||
keep[n - 1] = true;
|
||||
rdp(&ext, 0, n - 1, o.polygon_tol, &mut keep);
|
||||
}
|
||||
let mut poly: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
if closed && poly.len() < 3 {
|
||||
poly = pts.to_vec();
|
||||
}
|
||||
if poly.len() < if closed { 3 } else { 2 } {
|
||||
return None;
|
||||
}
|
||||
let sp = SubPath::polyline(&poly, closed);
|
||||
let fitted =
|
||||
simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false });
|
||||
let mut sp = fitted.subpaths.into_iter().next()?;
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if o.snap_lines {
|
||||
snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5));
|
||||
}
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// Retract the handles of curves that stay within `tol` of their chord.
|
||||
fn snap_to_lines(sp: &mut SubPath, tol: f64) {
|
||||
let n = sp.anchors.len();
|
||||
for i in 0..sp.segment_count() {
|
||||
let j = (i + 1) % n;
|
||||
let (a, b) = (sp.anchors[i], sp.anchors[j]);
|
||||
if !a.has_out() && !b.has_in() {
|
||||
continue;
|
||||
}
|
||||
let c = CubicBez::new(a.p, a.h_out, b.h_in, b.p);
|
||||
if (1..8).all(|k| seg_dist(c.eval(k as f64 / 8.0), a.p, b.p) <= tol) {
|
||||
sp.anchors[i].h_out = a.p;
|
||||
sp.anchors[j].h_in = b.p;
|
||||
}
|
||||
}
|
||||
for a in &mut sp.anchors {
|
||||
if !a.has_in() || !a.has_out() {
|
||||
a.kind = AnchorKind::Corner;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Gradients: areas whose colour changes evenly along one direction are found before the image is
|
||||
//! reduced to its palette, and each is traced as one path filled with a linear gradient instead of
|
||||
//! as bands of flat colour.
|
||||
//!
|
||||
//! An area is a connected run of pixels that each differ little from their neighbours. It is a
|
||||
//! gradient when a plane fits each of its colour channels with all three sloping the same way, the
|
||||
//! colours along that way are a few stops with even ramps between them, and painting those stops
|
||||
//! back gives the area's own pixels. Anything else (a flat area, a photo's sky lit two ways, two
|
||||
//! flats with a soft edge between them) is left to be traced as before.
|
||||
|
||||
use crate::Raster;
|
||||
use crate::quantize::TRANSPARENT;
|
||||
use kurbo::{Point, Vec2};
|
||||
|
||||
/// The largest step (levels of a channel) between neighbouring pixels of one smooth area.
|
||||
const STEP: i32 = 8;
|
||||
/// The fewest pixels a gradient has, and the least length (px) along its direction.
|
||||
const MIN_PIXELS: usize = 400;
|
||||
const MIN_LENGTH: f64 = 16.0;
|
||||
/// The least change (levels of a channel) from one stop to another: less is a flat colour.
|
||||
const MIN_CHANGE: f64 = 24.0;
|
||||
/// The most stops, and the least distance between two (as a share of the length): closer stops are
|
||||
/// an edge, not a ramp.
|
||||
const MAX_STOPS: usize = 6;
|
||||
const MIN_SPACING: f64 = 0.08;
|
||||
/// How far (levels) the stops may be from the colours along the gradient.
|
||||
const STOP_TOLERANCE: f64 = 2.0;
|
||||
|
||||
/// A linear gradient a traced path is filled with.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TracedGradient {
|
||||
/// The gradient's line, in pixel coordinates: the first stop's colour is at `start` and before
|
||||
/// it, the last one's at `end` and beyond.
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
/// The stops: how far along the line (0 to 1, ascending, the first 0 and the last 1) and the colour.
|
||||
pub stops: Vec<(f64, [u8; 3])>,
|
||||
}
|
||||
|
||||
impl TracedGradient {
|
||||
/// The gradient's colour at `p`.
|
||||
pub fn color_at(&self, p: Point) -> [f64; 3] {
|
||||
let d = self.end - self.start;
|
||||
let t = if d.hypot2() > 0.0 { ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let color = |i: usize| self.stops.get(i).map_or([0.0; 3], |s| s.1.map(f64::from));
|
||||
let after = self.stops.iter().position(|s| s.0 >= t).unwrap_or(self.stops.len().saturating_sub(1));
|
||||
let Some(before) = after.checked_sub(1) else { return color(after) };
|
||||
let (ta, tb) = (self.stops.get(before).map_or(0.0, |s| s.0), self.stops.get(after).map_or(1.0, |s| s.0));
|
||||
let k = if tb > ta { (t - ta) / (tb - ta) } else { 1.0 };
|
||||
let (a, b) = (color(before), color(after));
|
||||
[0, 1, 2].map(|c| a[c] + (b[c] - a[c]) * k)
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradients of `img`: for each pixel the gradient it is part of (an index into the list, or
|
||||
/// `u32::MAX`), and the list.
|
||||
pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let px = |i: usize| img.rgba.get(i * 4..i * 4 + 4).filter(|p| p[3] >= 128).map(|p| [i32::from(p[0]), i32::from(p[1]), i32::from(p[2])]);
|
||||
let near = |a: [i32; 3], b: Option<[i32; 3]>| b.is_some_and(|b| (0..3).all(|k| (a[k] - b[k]).abs() <= STEP));
|
||||
// Smooth: an opaque pixel that differs little from each neighbour it has.
|
||||
let smooth: Vec<bool> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
px(i).is_some_and(|c| {
|
||||
(x == 0 || near(c, px(i - 1)))
|
||||
&& (x + 1 == w || near(c, px(i + 1)))
|
||||
&& (y == 0 || near(c, px(i - w)))
|
||||
&& (y + 1 == h || near(c, px(i + w)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Sloping: smooth, and some pixel a little way off (a 32nd of the image: a gradient across a
|
||||
// large image changes by less than a level from one pixel to the next) is another colour.
|
||||
// Flat colour is no part of a gradient, and left out it does not join a gradient to the flat
|
||||
// area beside it where the two are nearly one colour.
|
||||
let far = (w.min(h) / 32).clamp(4, 64) as i64;
|
||||
let around = [
|
||||
(far, 0),
|
||||
(-far, 0),
|
||||
(0, far),
|
||||
(0, -far),
|
||||
(far * 3 / 4, far * 3 / 4),
|
||||
(far * 3 / 4, -far * 3 / 4),
|
||||
(-far * 3 / 4, far * 3 / 4),
|
||||
(-far * 3 / 4, -far * 3 / 4),
|
||||
];
|
||||
let sloping: Vec<bool> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
let differs = |(dx, dy): (i64, i64)| {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
let other = (nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| px(ny as usize * w + nx as usize)).flatten();
|
||||
px(i).zip(other).is_some_and(|(a, b)| (0..3).any(|k| (a[k] - b[k]).abs() >= 2))
|
||||
};
|
||||
smooth.get(i).copied().unwrap_or(false) && around.into_iter().any(differs)
|
||||
})
|
||||
.collect();
|
||||
drop(smooth);
|
||||
let smooth = sloping;
|
||||
let mut of = vec![u32::MAX; w * h];
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut depth = vec![0u16; w * h];
|
||||
let mut found = vec![];
|
||||
let (mut members, mut stack) = (vec![], vec![]);
|
||||
for seed in 0..w * h {
|
||||
if seen.get(seed).copied().unwrap_or(true) || !smooth.get(seed).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
// The connected smooth pixels from here.
|
||||
members.clear();
|
||||
stack.push(seed);
|
||||
if let Some(s) = seen.get_mut(seed) {
|
||||
*s = true;
|
||||
}
|
||||
while let Some(i) = stack.pop() {
|
||||
members.push(i);
|
||||
let (x, y) = (i % w, i / w);
|
||||
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
|
||||
if smooth.get(j).copied().unwrap_or(false) && !seen.get(j).copied().unwrap_or(true) {
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
if members.len() < MIN_PIXELS || found.len() >= 1000 {
|
||||
continue;
|
||||
}
|
||||
// The gradient is read from the area's inside, where there is one: the pixels at its rim
|
||||
// may be a soft edge to the next area, joined to this one where the two are alike.
|
||||
// (`depth`: how many pixels in from the rim each of the area's pixels is, counted inwards
|
||||
// from the pixels that have a neighbour outside it, as far as matters.)
|
||||
let rim = far as usize + 2;
|
||||
let unknown = u16::MAX;
|
||||
members.iter().for_each(|&i| depth[i] = unknown);
|
||||
let beside = |i: usize| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten()
|
||||
};
|
||||
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i).any(|j| depth.get(j).copied() == Some(0))).collect();
|
||||
front.iter().for_each(|&i| depth[i] = 1);
|
||||
for d in 2..=rim as u16 {
|
||||
let mut next = vec![];
|
||||
for &i in &front {
|
||||
for j in beside(i) {
|
||||
if let Some(slot) = depth.get_mut(j).filter(|s| **s == unknown) {
|
||||
*slot = d;
|
||||
next.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
front = next;
|
||||
}
|
||||
let inside: Vec<usize> = members.iter().copied().filter(|&i| depth.get(i).copied() == Some(unknown)).collect();
|
||||
let fitted = fit(img, w, if inside.len() >= MIN_PIXELS { &inside } else { &members }, &members, rim);
|
||||
members.iter().for_each(|&i| depth[i] = 0);
|
||||
if let Some((g, part)) = fitted {
|
||||
for &i in &part {
|
||||
if let Some(o) = of.get_mut(i) {
|
||||
*o = found.len() as u32;
|
||||
}
|
||||
}
|
||||
found.push(g);
|
||||
}
|
||||
}
|
||||
(of, found)
|
||||
}
|
||||
|
||||
/// The linear gradient the pixels `members` of `img` (`w` wide) are, if they are one, and those of
|
||||
/// `all` (the whole area `members` are the inside of, `rim` pixels in) that have its colours.
|
||||
fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) -> Option<(TracedGradient, Vec<usize>)> {
|
||||
let n = members.len() as f64;
|
||||
let at = |i: usize| Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
|
||||
let mut middle = Vec2::ZERO;
|
||||
let mut mean = [0.0; 3];
|
||||
for &i in members {
|
||||
middle += at(i).to_vec2();
|
||||
let c = rgb(i);
|
||||
(0..3).for_each(|k| mean[k] += c[k]);
|
||||
}
|
||||
let middle = (middle / n).to_point();
|
||||
let mean = mean.map(|v| v / n);
|
||||
// A plane through each channel, by least squares about the middle.
|
||||
let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
|
||||
let (mut scx, mut scy) = ([0.0; 3], [0.0; 3]);
|
||||
for &i in members {
|
||||
let (d, c) = (at(i) - middle, rgb(i));
|
||||
sxx += d.x * d.x;
|
||||
sxy += d.x * d.y;
|
||||
syy += d.y * d.y;
|
||||
for k in 0..3 {
|
||||
scx[k] += d.x * (c[k] - mean[k]);
|
||||
scy[k] += d.y * (c[k] - mean[k]);
|
||||
}
|
||||
}
|
||||
let det = sxx * syy - sxy * sxy;
|
||||
if det <= 1e-6 * sxx * syy || !det.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Each channel's slope, and the one direction they share: the longer axis of the three
|
||||
// together (the shorter is what they do not share, and must be next to nothing).
|
||||
let slope = [0, 1, 2].map(|k| Vec2::new((scx[k] * syy - scy[k] * sxy) / det, (scy[k] * sxx - scx[k] * sxy) / det));
|
||||
let (mxx, mxy, myy) = slope.iter().fold((0.0, 0.0, 0.0), |m, s| (m.0 + s.x * s.x, m.1 + s.x * s.y, m.2 + s.y * s.y));
|
||||
let spread = ((mxx - myy) / 2.0).hypot(mxy);
|
||||
let (major, minor) = ((mxx + myy) / 2.0 + spread, (mxx + myy) / 2.0 - spread);
|
||||
if major <= 0.0 || minor > 0.03 * major {
|
||||
return None;
|
||||
}
|
||||
let angle = 0.5 * (2.0 * mxy).atan2(mxx - myy);
|
||||
let dir = Vec2::new(angle.cos(), angle.sin());
|
||||
// The colours along it, a pixel's width at a time.
|
||||
let along = |i: usize| (at(i) - middle).dot(dir);
|
||||
let (t0, t1) = members.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, &i| (m.0.min(along(i) - 0.5), m.1.max(along(i) + 0.5)));
|
||||
let length = t1 - t0;
|
||||
if !(MIN_LENGTH..1e6).contains(&length) {
|
||||
return None;
|
||||
}
|
||||
let bins = length.ceil() as usize;
|
||||
// (Each step's mean colour, at the mean place of its pixels: the few pixels at the tip of a
|
||||
// round area are not at the middle of their step.)
|
||||
let mut sums = vec![([0.0; 3], 0.0f64, 0.0f64); bins];
|
||||
for &i in members {
|
||||
let t = along(i) - t0;
|
||||
if let Some(b) = sums.get_mut((t as usize).min(bins - 1)) {
|
||||
let c = rgb(i);
|
||||
(0..3).for_each(|k| b.0[k] += c[k]);
|
||||
b.1 += 1.0;
|
||||
b.2 += t;
|
||||
}
|
||||
}
|
||||
let curve: Vec<(f64, [f64; 3])> = sums.iter().filter(|b| b.1 > 0.0).map(|b| (b.2 / b.1 / length, b.0.map(|v| v / b.1))).collect();
|
||||
// The fewest stops whose ramps pass within tolerance of every colour along the way: the two
|
||||
// ends, then again and again the colour farthest from the ramp it lies on.
|
||||
let mut keep = vec![0, curve.len().checked_sub(1)?];
|
||||
loop {
|
||||
let mut worst = (STOP_TOLERANCE, 0);
|
||||
for pair in keep.windows(2) {
|
||||
let (Some(&(ta, a)), Some(&(tb, b))) = (curve.get(pair[0]), curve.get(pair[1])) else { return None };
|
||||
for (j, &(t, c)) in curve.iter().enumerate().take(pair[1]).skip(pair[0] + 1) {
|
||||
let k = (t - ta) / (tb - ta);
|
||||
let off = (0..3).map(|ch| (c[ch] - (a[ch] + (b[ch] - a[ch]) * k)).abs()).fold(0.0, f64::max);
|
||||
if off > worst.0 {
|
||||
worst = (off, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst.1 == 0 {
|
||||
break;
|
||||
}
|
||||
// (More than are let stand: those close together become one below.)
|
||||
if keep.len() >= 4 * MAX_STOPS {
|
||||
return None;
|
||||
}
|
||||
keep.push(worst.1);
|
||||
keep.sort_unstable();
|
||||
}
|
||||
// Two stops close together are one stop where the ramp turns (a turn is not sharp to the
|
||||
// pixel, least of all in a blurred image), unless their colours are far apart: that is an
|
||||
// edge, not a ramp.
|
||||
let mut stops: Vec<(f64, [f64; 3])> = vec![];
|
||||
for &(t, c) in keep.iter().filter_map(|&j| curve.get(j)) {
|
||||
match stops.last_mut() {
|
||||
Some(last) if t - last.0 < MIN_SPACING => {
|
||||
if (0..3).any(|k| (c[k] - last.1[k]).abs() >= MIN_CHANGE) {
|
||||
return None;
|
||||
}
|
||||
*last = ((last.0 + t) / 2.0, [0, 1, 2].map(|k| (last.1[k] + c[k]) / 2.0));
|
||||
}
|
||||
_ => stops.push((t, c)),
|
||||
}
|
||||
}
|
||||
let (first, last) = (stops.first()?.0, stops.last()?.0);
|
||||
// A change of colour, not one colour.
|
||||
let change =
|
||||
stops.iter().flat_map(|a| stops.iter().map(move |b| (0..3).map(|k| (a.1[k] - b.1[k]).abs()).fold(0.0, f64::max))).fold(0.0, f64::max);
|
||||
// And ramps most of the way: two flat colours with a soft edge between them are no gradient.
|
||||
let level: f64 = stops.windows(2).filter(|s| (0..3).all(|k| (s[1].1[k] - s[0].1[k]).abs() < 4.0)).map(|s| s[1].0 - s[0].0).sum();
|
||||
if !(2..=MAX_STOPS).contains(&stops.len()) || last - first < MIN_SPACING || change < MIN_CHANGE || level > 0.3 * (last - first) {
|
||||
return None;
|
||||
}
|
||||
let gradient = TracedGradient {
|
||||
start: middle + dir * (t0 + first * length),
|
||||
end: middle + dir * (t0 + last * length),
|
||||
stops: stops.iter().map(|&(t, c)| ((t - first) / (last - first), c.map(|v| v.round().clamp(0.0, 255.0) as u8))).collect(),
|
||||
};
|
||||
// Carried on to the ends of the whole area, each end ramp going on as it goes.
|
||||
let d = gradient.end - gradient.start;
|
||||
let place = |i: usize| (at(i) - gradient.start).dot(d) / d.hypot2();
|
||||
let reach = rim as f64 * 1.5 / d.hypot();
|
||||
let (u0, u1) = all.iter().fold((0.0f64, 1.0f64), |m, &i| (m.0.min(place(i)), m.1.max(place(i))));
|
||||
let (u0, u1) = (u0.max(-reach), u1.min(1.0 + reach));
|
||||
let ramp = |a: (f64, [u8; 3]), b: (f64, [u8; 3]), u: f64| {
|
||||
[0, 1, 2].map(|k| (f64::from(a.1[k]) + (f64::from(b.1[k]) - f64::from(a.1[k])) * (u - a.0) / (b.0 - a.0)).round().clamp(0.0, 255.0) as u8)
|
||||
};
|
||||
let mut stops = gradient.stops.clone();
|
||||
if let (&[a, b, ..], &[.., y, z]) = (stops.as_slice(), stops.as_slice()) {
|
||||
if let Some(first) = stops.first_mut() {
|
||||
*first = (u0, ramp(a, b, u0));
|
||||
}
|
||||
if let Some(last) = stops.last_mut() {
|
||||
*last = (u1, ramp(y, z, u1));
|
||||
}
|
||||
}
|
||||
let gradient = TracedGradient {
|
||||
start: gradient.start + d * u0,
|
||||
end: gradient.start + d * u1,
|
||||
stops: stops.into_iter().map(|(u, c)| ((u - u0) / (u1 - u0), c)).collect(),
|
||||
};
|
||||
// Painted back, it is the area: most pixels of its inside within a few levels, and all that
|
||||
// are (the rest: noise, a soft rim, the flat colour just beyond it) on average nearer still.
|
||||
let off = |i: usize| {
|
||||
let (c, g) = (rgb(i), gradient.color_at(at(i)));
|
||||
(0..3).map(|k| (c[k] - g[k]).abs()).fold(0.0, f64::max)
|
||||
};
|
||||
let part: Vec<usize> = all.iter().copied().filter(|&i| off(i) <= 6.0).collect();
|
||||
let mean = part.iter().map(|&i| off(i)).sum::<f64>() / part.len().max(1) as f64;
|
||||
let inside = members.iter().filter(|&&i| off(i) <= 6.0).count();
|
||||
(part.len() >= MIN_PIXELS && inside * 4 >= members.len() * 3 && mean <= 3.5).then_some((gradient, part))
|
||||
}
|
||||
|
||||
/// Settles the pixels along the edge of each gradient (they are part gradient and part what lies
|
||||
/// beyond, and no part of the smooth area the gradient was found in): each joins the gradient
|
||||
/// (`of`) when more than half of it is the gradient's colour there, and else takes the label of
|
||||
/// what lies beyond. `labels` are the image's palette labels, the gradients' pixels not among them.
|
||||
pub(crate) fn settle_edges(img: &Raster, of: &mut [u32], gradients: &[TracedGradient], labels: &mut [u16]) {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 4).filter(|p| p[3] >= 128).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
|
||||
// Twice: a soft edge is two pixels wide.
|
||||
for _ in 0..2 {
|
||||
let mut joins: Vec<(usize, u32)> = vec![];
|
||||
let mut takes: Vec<(usize, u16)> = vec![];
|
||||
for i in 0..w * h {
|
||||
if of.get(i).copied() != Some(u32::MAX) {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = rgb(i) else { continue };
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
let index = |x: i64, y: i64| (x >= 0 && y >= 0 && x < w as i64 && y < h as i64).then(|| y as usize * w + x as usize);
|
||||
// The gradient on one side, and what lies two pixels out on the other.
|
||||
let Some((g, dx, dy)) = [(1, 0), (-1, 0), (0, 1), (0, -1)].into_iter().find_map(|(dx, dy)| {
|
||||
let g = index(x + dx, y + dy).and_then(|j| of.get(j).copied()).filter(|&g| g != u32::MAX)?;
|
||||
Some((g, dx, dy))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let Some(gradient) = gradients.get(g as usize) else { continue };
|
||||
let own = gradient.color_at(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let off = (0..3).map(|k| (c[k] - own[k]).abs()).fold(0.0, f64::max);
|
||||
let beyond = index(x - 2 * dx, y - 2 * dy).filter(|&j| of.get(j).copied() == Some(u32::MAX));
|
||||
let other = beyond.and_then(|j| Some((rgb(j)?, labels.get(j).copied()?)));
|
||||
match other {
|
||||
Some((o, label)) if (0..3).map(|k| (o[k] - own[k]).abs()).fold(0.0, f64::max) > 16.0 => {
|
||||
// How much of the pixel is the other colour.
|
||||
let d = [0, 1, 2].map(|k| o[k] - own[k]);
|
||||
let share = (0..3).map(|k| (c[k] - own[k]) * d[k]).sum::<f64>() / d.iter().map(|v| v * v).sum::<f64>();
|
||||
if share < 0.5 {
|
||||
joins.push((i, g));
|
||||
} else if label != TRANSPARENT {
|
||||
takes.push((i, label));
|
||||
}
|
||||
}
|
||||
// Gradient beyond too, or nothing to tell it from: part of it if it has its colour.
|
||||
_ if off <= 16.0 => joins.push((i, g)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (i, g) in joins {
|
||||
if let Some(o) = of.get_mut(i) {
|
||||
*o = g;
|
||||
}
|
||||
}
|
||||
for (i, l) in takes {
|
||||
if let Some(slot) = labels.get_mut(i) {
|
||||
*slot = l;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ramp(w: u32, h: u32, f: impl Fn(f64, f64) -> [f64; 3]) -> Raster {
|
||||
Raster::from_fn(w, h, |x, y| {
|
||||
let c = f(f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
[c[0].round() as u8, c[1].round() as u8, c[2].round() as u8, 255]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_ramp_is_one_gradient_with_its_direction_and_ends() {
|
||||
// Dark blue to yellow, 30° down from level.
|
||||
let dir = Vec2::new(30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let reach = Vec2::new(120.0, 80.0).dot(dir);
|
||||
let img = ramp(120, 80, |x, y| {
|
||||
let t = Vec2::new(x, y).dot(dir) / reach;
|
||||
[20.0 + 220.0 * t, 40.0 + 180.0 * t, 160.0 - 140.0 * t]
|
||||
});
|
||||
let (of, found) = detect(&img);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(of.iter().all(|&g| g == 0));
|
||||
let g = &found[0];
|
||||
let d = g.end - g.start;
|
||||
assert!((d.atan2().to_degrees() - 30.0).abs() < 1.0, "{d:?}");
|
||||
assert_eq!(g.stops.len(), 2);
|
||||
for (stop, truth) in g.stops.iter().zip([[20.0, 40.0, 160.0], [240.0, 220.0, 20.0]]) {
|
||||
assert!((0..3).all(|k| (f64::from(stop.1[k]) - truth[k]).abs() < 6.0), "{:?}", g.stops);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_colour_soft_edges_and_two_way_shading_are_no_gradient() {
|
||||
// One colour.
|
||||
assert!(detect(&ramp(64, 64, |_, _| [200.0, 30.0, 30.0])).1.is_empty());
|
||||
// Two flat colours with a soft edge between them.
|
||||
let soft = ramp(96, 64, |x, _| [100.0 + 30.0 / (1.0 + (-(x - 48.0)).exp()); 3]);
|
||||
assert!(detect(&soft).1.is_empty());
|
||||
// Red changing across and green changing down.
|
||||
assert!(detect(&ramp(96, 96, |x, y| [40.0 + 2.0 * x, 40.0 + 2.0 * y, 90.0])).1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_stops_are_three_stops() {
|
||||
let img = ramp(200, 40, |x, _| {
|
||||
let t = x / 200.0;
|
||||
if t < 0.5 { [255.0 - 400.0 * t, 40.0, 40.0 + 300.0 * t] } else { [55.0 + 300.0 * (t - 0.5), 40.0 + 400.0 * (t - 0.5), 190.0] }
|
||||
});
|
||||
let (_, found) = detect(&img);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].stops.len(), 3, "{:?}", found[0].stops);
|
||||
assert!((found[0].stops[1].0 - 0.5).abs() < 0.03);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Auto Grouping: which traced path lies inside which, so that a result can be grouped the way it
|
||||
//! is nested (a ring with what fills its hole, a face with its eyes).
|
||||
|
||||
use crate::TracedPath;
|
||||
use kurbo::{PathEl, Point, Rect};
|
||||
|
||||
/// How far (px) outside an outline a point may lie and still count as inside it: two paths that
|
||||
/// share an edge are each fitted on their own.
|
||||
const NEAR: f64 = 0.5;
|
||||
|
||||
/// A path's outer contour as a polygon, and its box.
|
||||
struct Outer {
|
||||
pts: Vec<Point>,
|
||||
bbox: Rect,
|
||||
/// A closed, filled contour: something can lie inside it.
|
||||
holds: bool,
|
||||
/// Where something can: for each pixel row from `bbox.y0 - NEAR` down, the edges (each from
|
||||
/// the point of that index to the next) that pass through it or within [`NEAR`] of it. A
|
||||
/// point is tested against the edges of its row, not all of a long outline's.
|
||||
rows: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
fn outer(tp: &TracedPath) -> Option<Outer> {
|
||||
let (mut pts, mut subpaths, mut closed) = (vec![], 0, false);
|
||||
kurbo::flatten(tp.path.to_bezpath(), 0.2, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
subpaths += 1;
|
||||
if subpaths == 1 {
|
||||
pts.push(p);
|
||||
}
|
||||
}
|
||||
PathEl::LineTo(p) if subpaths == 1 => pts.push(p),
|
||||
PathEl::ClosePath if subpaths == 1 => closed = true,
|
||||
_ => {}
|
||||
});
|
||||
let first = *pts.first()?;
|
||||
let bbox = pts.iter().fold(Rect::from_points(first, first), |b, p| b.union_pt(*p));
|
||||
if !bbox.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let holds = closed && tp.stroke.is_none();
|
||||
// (Traced paths lie in the image: a box taller than any image is nothing to hold things.)
|
||||
let holds = holds && bbox.height() < 1e6 && u32::try_from(pts.len()).is_ok();
|
||||
let mut rows = vec![];
|
||||
if holds {
|
||||
rows = vec![vec![]; (bbox.height() + 2.0 * NEAR) as usize + 1];
|
||||
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
|
||||
let (top, bottom) = (a.y.min(b.y) - bbox.y0, a.y.max(b.y) - bbox.y0 + 2.0 * NEAR);
|
||||
for row in rows.iter_mut().take(bottom as usize + 1).skip(top.max(0.0) as usize) {
|
||||
row.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Outer { pts, bbox, holds, rows })
|
||||
}
|
||||
|
||||
/// Whether `p` lies inside the closed outline of `o`, or within [`NEAR`] of its edge.
|
||||
fn inside_or_near(p: Point, o: &Outer) -> bool {
|
||||
let row = p.y - (o.bbox.y0 - NEAR);
|
||||
let Some(edges) = (row >= 0.0).then(|| o.rows.get(row as usize)).flatten() else { return false };
|
||||
let mut inside = false;
|
||||
for &i in edges {
|
||||
let (Some(a), Some(b)) = (o.pts.get(i as usize), o.pts.get((i as usize + 1) % o.pts.len().max(1))) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) {
|
||||
inside = !inside;
|
||||
}
|
||||
let d = *b - *a;
|
||||
let t = if d.hypot2() > 0.0 { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
if (p - (*a + d * t)).hypot2() <= NEAR * NEAR {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// For each of `paths` (bottom-most first), the path it lies inside, if any: the smallest filled
|
||||
/// path whose outer contour holds all of its own. The path that covers the whole `w` × `h` image
|
||||
/// (the paper) is nobody's parent: everything would be inside it.
|
||||
///
|
||||
/// Drawing each path followed by those inside it paints the picture `paths` paints in its own
|
||||
/// order: where the paths can overlap (`stacked`), a path only gets a parent below it, and only
|
||||
/// if no path that would then come after it instead of before it shares any of its box.
|
||||
pub(crate) fn parents(paths: &[TracedPath], w: usize, h: usize, stacked: bool) -> Vec<Option<usize>> {
|
||||
let outers: Vec<Option<Outer>> = paths.iter().map(outer).collect();
|
||||
let paper = Rect::new(1.0, 1.0, w as f64 - 1.0, h as f64 - 1.0);
|
||||
let mut parent: Vec<Option<usize>> = vec![None; paths.len()];
|
||||
// How many paths each is inside of.
|
||||
let mut depth = vec![0usize; paths.len()];
|
||||
// The paths something can lie inside, by the area of their boxes.
|
||||
let mut by_area: Vec<usize> = (0..paths.len()).filter(|&p| outers.get(p).and_then(Option::as_ref).is_some_and(|o| o.holds)).collect();
|
||||
let area = |p: &usize| outers.get(*p).and_then(Option::as_ref).map_or(0.0, |o| o.bbox.area());
|
||||
by_area.sort_by(|a, b| area(a).total_cmp(&area(b)));
|
||||
for (x, ox) in outers.iter().enumerate() {
|
||||
let Some(ox) = ox else { continue };
|
||||
// The paths whose box holds its own, smallest first.
|
||||
let mut holders = by_area.iter().filter_map(|&p| Some((p, outers.get(p)?.as_ref()?))).filter(|&(p, op)| {
|
||||
let (o, i) = (op.bbox, ox.bbox);
|
||||
o.x0 - NEAR <= i.x0
|
||||
&& o.y0 - NEAR <= i.y0
|
||||
&& o.x1 + NEAR >= i.x1
|
||||
&& o.y1 + NEAR >= i.y1
|
||||
&& o.area() > i.area()
|
||||
&& !(stacked && p > x)
|
||||
&& !(o.x0 <= paper.x0 && o.y0 <= paper.y0 && o.x1 >= paper.x1 && o.y1 >= paper.y1)
|
||||
});
|
||||
// The paths under `x` that share some of its box: it may not come to lie under any of them.
|
||||
// (Found only once some path holds its first point: most paths are inside none.)
|
||||
let under = std::cell::OnceCell::new();
|
||||
let under = || {
|
||||
under.get_or_init(|| {
|
||||
(0..x)
|
||||
.filter(|&y| outers.get(y).and_then(Option::as_ref).is_some_and(|oy| oy.bbox.intersect(ox.bbox).area() > 0.0))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
let found = holders.find(|&(p, op)| {
|
||||
ox.pts.first().is_some_and(|pt| inside_or_near(*pt, op))
|
||||
&& (!stacked || under().iter().all(|&y| !comes_after(&parent, &depth, y, p)))
|
||||
&& ox.pts.iter().all(|pt| inside_or_near(*pt, op))
|
||||
});
|
||||
if let (Some((p, _)), Some(slot)) = (found, parent.get_mut(x)) {
|
||||
*slot = Some(p);
|
||||
let deep = depth.get(p).copied().unwrap_or(0) + 1;
|
||||
if let Some(d) = depth.get_mut(x) {
|
||||
*d = deep;
|
||||
}
|
||||
}
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
/// Whether `y` is drawn after `p` and all that is inside `p`: it is in another branch of the
|
||||
/// nesting, and that branch starts above the one `p` is in.
|
||||
fn comes_after(parent: &[Option<usize>], depth: &[usize], y: usize, p: usize) -> bool {
|
||||
let up = |i: usize| parent.get(i).copied().flatten();
|
||||
let deep = |i: usize| depth.get(i).copied().unwrap_or(0);
|
||||
let (mut a, mut b) = (y, p);
|
||||
while deep(a) > deep(b) {
|
||||
let Some(q) = up(a) else { break };
|
||||
a = q;
|
||||
}
|
||||
while deep(b) > deep(a) {
|
||||
let Some(q) = up(b) else { break };
|
||||
b = q;
|
||||
}
|
||||
// (Equal: one is the other, or inside it.)
|
||||
while a != b && up(a) != up(b) {
|
||||
let (Some(qa), Some(qb)) = (up(a), up(b)) else { break };
|
||||
(a, b) = (qa, qb);
|
||||
}
|
||||
a > b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
fn disc(c: (f64, f64), r: f64, color: u8) -> TracedPath {
|
||||
let path = PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Circle::new(c, r), 0.01));
|
||||
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_is_inside_the_smallest_path_that_holds_it() {
|
||||
// The paper, a disc, a disc inside it, a dot inside that, and a disc beside them.
|
||||
let paths = [
|
||||
disc((50.0, 50.0), 80.0, 255),
|
||||
disc((30.0, 50.0), 20.0, 0),
|
||||
disc((30.0, 50.0), 10.0, 100),
|
||||
disc((30.0, 50.0), 3.0, 0),
|
||||
disc((75.0, 50.0), 10.0, 0),
|
||||
];
|
||||
for stacked in [false, true] {
|
||||
assert_eq!(parents(&paths, 100, 100, stacked), [None, None, Some(1), Some(2), None]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_paths_are_not_reordered_where_they_overlap() {
|
||||
// A dot over a disc, with a wider disc between them in the stack that covers the dot's
|
||||
// place without holding the first disc: grouped with the first disc, the dot would go under it.
|
||||
let paths = [disc((30.0, 50.0), 20.0, 0), disc((60.0, 50.0), 24.0, 100), disc((35.0, 50.0), 3.0, 200)];
|
||||
assert_eq!(parents(&paths, 100, 100, true), [None, None, None]);
|
||||
// Where paths cannot overlap, either order paints the same.
|
||||
assert_eq!(parents(&paths, 100, 100, false), [None, None, Some(0)]);
|
||||
}
|
||||
}
|
||||
+214
-70
@@ -34,8 +34,9 @@
|
||||
//! Lines also straightens slightly curved runs and makes arcs of parts of circles.
|
||||
//!
|
||||
//! With **Create: Strokes**, the parts of a colour layer no wider than the stroke width are traced
|
||||
//! as stroked centre lines instead (see `centerline`); without Fills, wider parts become stroked
|
||||
//! outlines.
|
||||
//! as stroked centre lines instead (see `centerline`): placed in the middle of their ink to a
|
||||
//! fraction of a pixel, as wide as their ink, and fitted as outlines are. Without Fills, wider
|
||||
//! parts become stroked outlines.
|
||||
//!
|
||||
//! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or
|
||||
//! *overlapping* (stacked: each layer also covers every layer above it, so no hairline gaps).
|
||||
@@ -44,9 +45,12 @@
|
||||
mod centerline;
|
||||
mod contour;
|
||||
mod fit;
|
||||
mod gradient;
|
||||
mod group;
|
||||
mod logo;
|
||||
mod mosaic;
|
||||
mod quantize;
|
||||
mod shapes;
|
||||
mod smooth;
|
||||
mod subpixel;
|
||||
|
||||
@@ -56,8 +60,10 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
pub use contour::{Component, Loop, trace_mask};
|
||||
pub use gradient::TracedGradient;
|
||||
pub use mosaic::mosaic;
|
||||
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
|
||||
pub use shapes::TracedShape;
|
||||
use subpixel::Crack;
|
||||
|
||||
/// Errors decoding a raster.
|
||||
@@ -269,11 +275,23 @@ 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,
|
||||
/// Shapes: a filled outline that is a circle, an ellipse, a rectangle (square or rounded
|
||||
/// corners) or a regular polygon comes out as exactly that shape ([`TracedPath::shape`]).
|
||||
pub shapes: bool,
|
||||
/// Auto Grouping: say which path lies inside which ([`TraceResult::parents`]).
|
||||
pub auto_grouping: bool,
|
||||
/// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction
|
||||
/// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour.
|
||||
pub gradients: bool,
|
||||
/// Create: trace areas as filled shapes.
|
||||
pub fills: bool,
|
||||
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
|
||||
@@ -302,7 +320,11 @@ impl Default for TraceParams {
|
||||
noise: 25,
|
||||
method: Method::Abutting,
|
||||
ignore_white: false,
|
||||
ignore_color: None,
|
||||
snap_curves_to_lines: false,
|
||||
shapes: false,
|
||||
auto_grouping: false,
|
||||
gradients: false,
|
||||
fills: true,
|
||||
strokes: false,
|
||||
stroke_width: 10.0,
|
||||
@@ -312,20 +334,10 @@ impl Default for TraceParams {
|
||||
}
|
||||
|
||||
impl TraceParams {
|
||||
/// Maximum distance (px) of polygon vertices from the pixel boundary.
|
||||
fn polygon_tolerance(&self) -> f64 {
|
||||
let f = (self.paths / 100.0).clamp(0.0, 1.0);
|
||||
0.55 + 0.75 * (1.0 - f)
|
||||
}
|
||||
/// Curve fitting tolerance (px).
|
||||
fn fit_tolerance(&self) -> f64 {
|
||||
let f = (self.paths / 100.0).clamp(0.0, 1.0);
|
||||
0.2 + 1.6 * (1.0 - f)
|
||||
}
|
||||
/// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100,
|
||||
/// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0).
|
||||
/// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones
|
||||
/// and makes parts of circles true arcs.
|
||||
/// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones,
|
||||
/// makes parts of circles true arcs, and makes lines within 2.5° of level or upright exactly so.
|
||||
fn outline_style(&self) -> logo::Style {
|
||||
// Parameters come from users and agents: a NaN or infinity must not reach the geometry.
|
||||
let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default };
|
||||
@@ -340,19 +352,16 @@ impl TraceParams {
|
||||
circle_tol: if snap { 0.3 } else { 0.15 },
|
||||
restore: Some(logo::RestoreParams::default()),
|
||||
check_step: 0.15,
|
||||
upright: snap,
|
||||
..logo::Style::default()
|
||||
}
|
||||
}
|
||||
/// Turn angle (degrees) above which a polygon vertex stays a corner.
|
||||
fn corner_angle(&self) -> f64 {
|
||||
let c = (self.corners / 100.0).clamp(0.0, 1.0);
|
||||
150.0 - 115.0 * c
|
||||
}
|
||||
}
|
||||
|
||||
/// 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",
|
||||
@@ -364,6 +373,7 @@ pub const PRESET_NAMES: &[&str] = &[
|
||||
"Silhouettes",
|
||||
"Line Art",
|
||||
"Technical Drawing",
|
||||
"Outline",
|
||||
"Flat Logo",
|
||||
];
|
||||
|
||||
@@ -392,6 +402,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),
|
||||
@@ -403,6 +417,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,
|
||||
@@ -426,6 +442,10 @@ pub struct TracedPath {
|
||||
/// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the
|
||||
/// outlines of wider areas without Fills).
|
||||
pub stroke: Option<f64>,
|
||||
/// Shapes: the shape the path is, exactly (`path` is that shape's outline).
|
||||
pub shape: Option<TracedShape>,
|
||||
/// Gradients: the linear gradient the path is filled with (`color` is its middle colour).
|
||||
pub gradient: Option<TracedGradient>,
|
||||
}
|
||||
|
||||
/// The traced result, bottom-most path first.
|
||||
@@ -434,6 +454,9 @@ pub struct TraceResult {
|
||||
pub paths: Vec<TracedPath>,
|
||||
/// Colours actually used.
|
||||
pub palette: Vec<[u8; 3]>,
|
||||
/// Auto Grouping: for each of `paths`, the path it lies inside, if any (empty without the
|
||||
/// option). Each path drawn before those inside it paints what `paths` paints in its order.
|
||||
pub parents: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
impl TraceResult {
|
||||
@@ -446,6 +469,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,19 +492,63 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
if w == 0 || h == 0 || img.rgba.len() != w * h * 4 {
|
||||
return Ok(TraceResult::default());
|
||||
}
|
||||
let mut out = trace_paths(img, params, max_anchors)?;
|
||||
if params.auto_grouping {
|
||||
// Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can.
|
||||
let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes;
|
||||
out.parents = group::parents(&out.paths, img.width as usize, img.height as usize, stacked);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
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);
|
||||
// Gradients are found first and kept out of the palette: no colour is spent on bands of them.
|
||||
let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(img)).filter(|f| !f.1.is_empty());
|
||||
let without = found.as_ref().map(|(of, _)| {
|
||||
let mut rest = img.clone();
|
||||
for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) {
|
||||
if g != u32::MAX {
|
||||
p[3] = 0;
|
||||
}
|
||||
}
|
||||
rest
|
||||
});
|
||||
let mut q = quantize(without.as_ref().unwrap_or(img), params);
|
||||
drop(without);
|
||||
// Each gradient is a palette entry of its own (its colour: the gradient's middle).
|
||||
let mut gradients: Vec<Option<TracedGradient>> = vec![None; q.palette.len()];
|
||||
if let Some((mut of, found)) = found {
|
||||
gradient::settle_edges(img, &mut of, &found, &mut q.labels);
|
||||
let first = q.palette.len();
|
||||
for (l, &g) in q.labels.iter_mut().zip(&of) {
|
||||
if g != u32::MAX {
|
||||
*l = u16::try_from(first + g as usize).unwrap_or(TRANSPARENT);
|
||||
}
|
||||
}
|
||||
q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8)));
|
||||
gradients.extend(found.into_iter().map(Some));
|
||||
}
|
||||
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.
|
||||
@@ -497,27 +572,29 @@ 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,
|
||||
gradients: &gradients,
|
||||
bw_tones: bw_tones(img, &q),
|
||||
style: params.outline_style(),
|
||||
opts: fit::FitOptions {
|
||||
polygon_tol: params.polygon_tolerance(),
|
||||
fit_tol: params.fit_tolerance(),
|
||||
corner_angle: params.corner_angle(),
|
||||
snap_lines: params.snap_curves_to_lines,
|
||||
},
|
||||
w,
|
||||
h,
|
||||
anchors: AtomicUsize::new(0),
|
||||
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) {
|
||||
@@ -527,17 +604,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.
|
||||
@@ -547,12 +639,16 @@ 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)>>,
|
||||
/// Gradients: the gradient each palette entry is, if it is one.
|
||||
gradients: &'a [Option<TracedGradient>],
|
||||
/// Black and White: the luminance of white and of black (see `bw_tones`).
|
||||
bw_tones: [f64; 2],
|
||||
/// How outlines are fitted.
|
||||
/// How outlines and centre lines are fitted.
|
||||
style: logo::Style,
|
||||
/// How centre lines are fitted.
|
||||
opts: fit::FitOptions,
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Anchor points made so far, by every layer.
|
||||
@@ -564,7 +660,7 @@ 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.
|
||||
@@ -609,7 +705,7 @@ impl Layers<'_> {
|
||||
/// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5).
|
||||
///
|
||||
/// Each pixel of the column across the crack is read as a blend of the tones on its two sides
|
||||
/// (the two pixels' palette colours; in Black and White the mean luminance of the black and of
|
||||
/// (the 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
|
||||
@@ -630,12 +726,31 @@ 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 };
|
||||
(ca.map(f64::from), cb.map(f64::from))
|
||||
// A side's own colour: its palette entry's, or its gradient's colour here.
|
||||
let own = |l: u16, k: i32| {
|
||||
let flat = self.q.palette.get(l as usize)?.map(f64::from);
|
||||
let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) };
|
||||
let i = c.at(k)?;
|
||||
Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5)))
|
||||
};
|
||||
let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 };
|
||||
(ca, cb)
|
||||
};
|
||||
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
@@ -689,7 +804,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;
|
||||
}
|
||||
@@ -699,40 +813,59 @@ impl Layers<'_> {
|
||||
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
|
||||
/// maximum.
|
||||
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
|
||||
let (params, opts) = (self.params, &self.opts);
|
||||
let params = self.params;
|
||||
let color = *self.q.palette.get(ci)?;
|
||||
let overlapping = params.method == Method::Overlapping;
|
||||
let (w, h) = (self.w, self.h);
|
||||
// Create Strokes: this colour's lines, left out of its areas.
|
||||
let (thin, lines) = if params.strokes {
|
||||
centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, opts)
|
||||
// The ink the lines are read against: the layer's colour; Black and White's own black.
|
||||
let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) };
|
||||
centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style)
|
||||
} 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;
|
||||
let mut paths = vec![];
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>| {
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>| {
|
||||
let n = path.anchor_count();
|
||||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||||
return false;
|
||||
}
|
||||
paths.push(TracedPath { path, color, pixels, stroke });
|
||||
// (A gradient fills; a stroked line of it is its middle colour.)
|
||||
let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none());
|
||||
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient });
|
||||
true
|
||||
};
|
||||
for comp in trace_mask(&mask, w, h) {
|
||||
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];
|
||||
@@ -742,12 +875,18 @@ impl Layers<'_> {
|
||||
}
|
||||
subs.extend(fit(hole));
|
||||
}
|
||||
if !add(PathData::new(subs), comp.pixels, area_stroke) {
|
||||
// Shapes: a filled outline without holes that is a shape becomes that shape.
|
||||
let shape = match subs.as_slice() {
|
||||
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only),
|
||||
_ => None,
|
||||
};
|
||||
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
|
||||
if !add(path, comp.pixels, area_stroke, shape) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for line in lines {
|
||||
if !add(line.path, line.pixels, Some(line.width)) {
|
||||
if !add(line.path, line.pixels, Some(line.width), None) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -756,9 +895,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();
|
||||
}
|
||||
@@ -787,7 +931,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -26,5 +26,6 @@ mod unmix;
|
||||
|
||||
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;
|
||||
@@ -25,6 +25,9 @@ pub(crate) struct Style {
|
||||
pub max_dev: f64,
|
||||
/// Spacing of the points of the fit that this check looks at.
|
||||
pub check_step: f64,
|
||||
/// Straight pieces within a few degrees of level or upright are made exactly so (by the
|
||||
/// tracer's other modes; Flat Logo leaves them).
|
||||
pub upright: bool,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
@@ -42,6 +45,7 @@ impl Default for Style {
|
||||
restore: Some(RestoreParams::default()),
|
||||
max_dev: 1.4,
|
||||
check_step: 0.03,
|
||||
upright: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +478,14 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
if !out.palette.contains(&color) {
|
||||
out.palette.push(color);
|
||||
}
|
||||
out.paths.push(TracedPath { path: PathData::new(subs), color, pixels: (s.area * (factor * factor) as f64) as usize, stroke: None });
|
||||
out.paths.push(TracedPath {
|
||||
path: PathData::new(subs),
|
||||
color,
|
||||
pixels: (s.area * (factor * factor) as f64) as usize,
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Shapes: traced outlines that are a circle, an ellipse, a rectangle (its corners square or
|
||||
//! rounded) or a regular polygon are told so, and come out as exactly that shape.
|
||||
//!
|
||||
//! An outline is a shape when every point of it lies within a small tolerance of the shape that
|
||||
//! its own area, middle and extents describe: nothing is taken for a shape that it merely
|
||||
//! resembles.
|
||||
|
||||
use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, SubPath, shapes};
|
||||
|
||||
/// A shape an outline was found to be (pixel coordinates; angles in degrees, clockwise on screen).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum TracedShape {
|
||||
/// A rectangle `width` × `height` about `center`, turned by `rotation`, its corners rounded
|
||||
/// with `radius` (0: square corners).
|
||||
Rectangle { center: Point, width: f64, height: f64, radius: f64, rotation: f64 },
|
||||
/// An ellipse with radii `rx` and `ry` about `center`, turned by `rotation` (a circle: equal
|
||||
/// radii, no rotation).
|
||||
Ellipse { center: Point, rx: f64, ry: f64, rotation: f64 },
|
||||
/// A regular polygon: its first corner `rotation` from straight up.
|
||||
Polygon { center: Point, radius: f64, sides: u32, rotation: f64 },
|
||||
}
|
||||
|
||||
impl TracedShape {
|
||||
/// The shape's outline.
|
||||
pub fn path(&self) -> PathData {
|
||||
let turned = |p: PathData, c: Point, deg: f64| p.transformed(Affine::rotate_about(deg.to_radians(), c));
|
||||
match *self {
|
||||
TracedShape::Rectangle { center, width, height, radius, rotation } => {
|
||||
let r = Rect::from_center_size(center, (width, height));
|
||||
turned(if radius > 0.0 { shapes::rounded_rectangle(r, radius) } else { shapes::rectangle(r) }, center, rotation)
|
||||
}
|
||||
TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
turned(shapes::ellipse(Rect::from_center_size(center, (2.0 * rx, 2.0 * ry))), center, rotation)
|
||||
}
|
||||
TracedShape::Polygon { center, radius, sides, rotation } => shapes::polygon(center, radius, sides, rotation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a point may lie from the shape: half a pixel, and for large shapes 0.2% of their size.
|
||||
fn tolerance(size: f64) -> f64 {
|
||||
(0.002 * size).max(0.5)
|
||||
}
|
||||
|
||||
/// The shape the closed outline `sp` is, if it is one (and at least 6 px across).
|
||||
pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
|
||||
if !sp.closed || sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// Points all along the outline.
|
||||
let pts: Vec<Point> = (0..sp.segment_count()).flat_map(|i| (0..24).map(move |k| sp.segment(i).eval(f64::from(k) / 24.0))).collect();
|
||||
let n = pts.len();
|
||||
if n < 8 || !pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Area, middle and second moments of the polygon through them.
|
||||
let (mut area2, mut cx, mut cy) = (0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let cross = a.x * b.y - b.x * a.y;
|
||||
area2 += cross;
|
||||
cx += (a.x + b.x) * cross;
|
||||
cy += (a.y + b.y) * cross;
|
||||
}
|
||||
let area = area2.abs() / 2.0;
|
||||
if area < 20.0 {
|
||||
return None;
|
||||
}
|
||||
let c = Point::new(cx / (3.0 * area2), cy / (3.0 * area2));
|
||||
let (mut ixx, mut iyy, mut ixy) = (0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i] - c, pts[(i + 1) % n] - c);
|
||||
let cross = a.x * b.y - b.x * a.y;
|
||||
ixx += (a.x * a.x + a.x * b.x + b.x * b.x) * cross;
|
||||
iyy += (a.y * a.y + a.y * b.y + b.y * b.y) * cross;
|
||||
ixy += (a.x * b.y + 2.0 * a.x * a.y + 2.0 * b.x * b.y + b.x * a.y) * cross;
|
||||
}
|
||||
// Per unit of area: the variances of x and y and their covariance.
|
||||
let (sxx, syy, sxy) = (ixx / (6.0 * area2), iyy / (6.0 * area2), ixy / (12.0 * area2));
|
||||
let mean = (sxx + syy) / 2.0;
|
||||
let spread = ((sxx - syy) / 2.0).hypot(sxy);
|
||||
let (major, minor) = (mean + spread, (mean - spread).max(0.0));
|
||||
// The direction of the longer axis (none when the shape is as wide every way).
|
||||
let axis = if spread < 0.01 * mean { 0.0 } else { 0.5 * (2.0 * sxy).atan2(sxx - syy) };
|
||||
let size = 4.0 * major.sqrt();
|
||||
let tol = tolerance(size);
|
||||
if size < 6.0 || !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let local = |p: Point, angle: f64| {
|
||||
let d = p - c;
|
||||
(d.x * angle.cos() + d.y * angle.sin(), d.y * angle.cos() - d.x * angle.sin())
|
||||
};
|
||||
|
||||
// An ellipse: its radii are twice the roots of its variances.
|
||||
let (rx, ry) = (2.0 * major.sqrt(), 2.0 * minor.sqrt());
|
||||
if ry > 2.0 {
|
||||
let off = pts.iter().map(|&p| {
|
||||
let (x, y) = local(p, axis);
|
||||
// Scaled to a circle: how far from it, in pixels of the larger radius (no nearer than
|
||||
// that to the ellipse: a long thin bar is no flat ellipse).
|
||||
((x / rx).hypot(y / ry) - 1.0).abs() * rx
|
||||
});
|
||||
if off.fold(0.0, f64::max) <= tol {
|
||||
// Radii this close are a circle's, of the outline's area: a circle without anti-aliasing
|
||||
// is fitted a little wider one way than the other.
|
||||
let circle = (rx - ry).abs() <= 2.0 * tol;
|
||||
let (rx, ry) = if circle { ((rx * ry).sqrt(), (rx * ry).sqrt()) } else { (rx, ry) };
|
||||
return Some(TracedShape::Ellipse { center: c, rx, ry, rotation: if circle { 0.0 } else { axis.to_degrees() } });
|
||||
}
|
||||
}
|
||||
|
||||
// A rectangle, along its longest straight side (a square has no longer axis to go by).
|
||||
let side = (0..sp.segment_count())
|
||||
.filter(|&i| sp.segment_is_line(i))
|
||||
.map(|i| sp.segment(i))
|
||||
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)));
|
||||
if let Some(side) = side {
|
||||
let d = side.p3 - side.p0;
|
||||
// Turned by under 45° either way: a rectangle is the same a quarter turn on.
|
||||
let quarter = std::f64::consts::FRAC_PI_2;
|
||||
let angle = d.y.atan2(d.x);
|
||||
let angle = angle - quarter * (angle / quarter).round();
|
||||
let xy: Vec<(f64, f64)> = pts.iter().map(|&p| local(p, angle)).collect();
|
||||
let (hw, hh) = xy.iter().fold((0.0f64, 0.0f64), |m, p| (m.0.max(p.0.abs()), m.1.max(p.1.abs())));
|
||||
// Square corners first; else rounded ones, which take (4 − π) r² off the area.
|
||||
let rounded = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt().min(hw.min(hh));
|
||||
for radius in [0.0, rounded] {
|
||||
let off = xy.iter().map(|&(x, y)| {
|
||||
// Distance to the rounded rectangle's edge.
|
||||
let (qx, qy) = (x.abs() - hw + radius, y.abs() - hh + radius);
|
||||
(qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs()
|
||||
});
|
||||
if hw > 2.0 && hh > 2.0 && (radius == 0.0 || radius >= 0.75) && off.fold(0.0, f64::max) <= tol {
|
||||
return Some(TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A regular polygon: its corners evenly round the middle, its sides straight between them.
|
||||
let sides = sp.anchors.len();
|
||||
if (3..=12).contains(&sides) {
|
||||
let radius = sp.anchors.iter().map(|a| a.p.distance(c)).sum::<f64>() / sides as f64;
|
||||
let first = sp.anchors.first()?.p - c;
|
||||
let rotation = (first.y.atan2(first.x).to_degrees() + 90.0).rem_euclid(360.0 / sides as f64);
|
||||
let shape = TracedShape::Polygon { center: c, radius, sides: sides as u32, rotation };
|
||||
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
|
||||
// Every corner of the polygon has one of the outline's on it, and every point of the
|
||||
// outline lies on one of the polygon's sides.
|
||||
let cornered = ideal.iter().all(|q| sp.anchors.iter().any(|a| q.distance(a.p) <= 1.5 * tol));
|
||||
let on_a_side = |p: Point| {
|
||||
(0..ideal.len()).any(|i| {
|
||||
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
|
||||
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
|
||||
p.distance(a + (b - a) * t) <= tol
|
||||
})
|
||||
};
|
||||
if cornered && pts.iter().all(|&p| on_a_side(p)) {
|
||||
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
|
||||
let side = radius * std::f64::consts::SQRT_2;
|
||||
let turned = (rotation + 45.0).rem_euclid(90.0);
|
||||
let square = TracedShape::Rectangle {
|
||||
center: c,
|
||||
width: side,
|
||||
height: side,
|
||||
radius: 0.0,
|
||||
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
|
||||
};
|
||||
return Some(if sides == 4 { square } else { shape });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is(shape: TracedShape) -> Option<TracedShape> {
|
||||
recognise(shape.path().subpaths.first().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_shape_is_told_from_its_own_outline() {
|
||||
let c = Point::new(60.3, 45.6);
|
||||
match is(TracedShape::Ellipse { center: c, rx: 20.0, ry: 20.0, rotation: 0.0 }) {
|
||||
Some(TracedShape::Ellipse { center, rx, ry, rotation }) => {
|
||||
assert!(center.distance(c) < 0.05 && (rx - 20.0).abs() < 0.05 && rx == ry && rotation == 0.0, "{center:?} {rx} {ry}")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match is(TracedShape::Ellipse { center: c, rx: 30.0, ry: 12.0, rotation: 25.0 }) {
|
||||
Some(TracedShape::Ellipse { rx, ry, rotation, .. }) => {
|
||||
assert!((rx - 30.0).abs() < 0.3 && (ry - 12.0).abs() < 0.3 && (rotation - 25.0).abs() < 0.5, "{rx} {ry} {rotation}")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match is(TracedShape::Rectangle { center: c, width: 50.0, height: 20.0, radius: 0.0, rotation: 10.0 }) {
|
||||
Some(TracedShape::Rectangle { width, height, radius, rotation, .. }) => {
|
||||
assert!((width - 50.0).abs() < 0.1 && (height - 20.0).abs() < 0.1 && radius == 0.0 && (rotation - 10.0).abs() < 0.1)
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match is(TracedShape::Rectangle { center: c, width: 50.0, height: 30.0, radius: 8.0, rotation: 0.0 }) {
|
||||
Some(TracedShape::Rectangle { width, radius, .. }) => {
|
||||
assert!((width - 50.0).abs() < 0.2 && (radius - 8.0).abs() < 0.5, "{width} {radius}")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
for sides in [3, 5, 6, 8] {
|
||||
match is(TracedShape::Polygon { center: c, radius: 25.0, sides, rotation: 7.0 }) {
|
||||
Some(TracedShape::Polygon { radius, sides: n, rotation, .. }) => {
|
||||
assert!(n == sides && (radius - 25.0).abs() < 0.1 && (rotation - 7.0).abs() < 0.1, "{sides}: {radius} {rotation}")
|
||||
}
|
||||
other => panic!("{sides} sides: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_only_resembles_a_shape_is_none() {
|
||||
// A star, an L, a rectangle with one corner cut, an egg.
|
||||
let star = shapes::star(Point::new(50.0, 50.0), 30.0, 12.0, 5, 0.0);
|
||||
assert_eq!(recognise(&star.subpaths[0]), None);
|
||||
let poly = |pts: &[(f64, f64)]| SubPath::polyline(&pts.iter().map(|&p| Point::from(p)).collect::<Vec<_>>(), true);
|
||||
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)])), None);
|
||||
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)])), None);
|
||||
let mut egg = shapes::ellipse(Rect::new(10.0, 10.0, 70.0, 50.0));
|
||||
egg.subpaths[0].anchors[2].p.x += 4.0;
|
||||
assert_eq!(recognise(&egg.subpaths[0]), None);
|
||||
// Too small to say, open, or not a number.
|
||||
assert_eq!(is(TracedShape::Ellipse { center: Point::new(5.0, 5.0), rx: 2.0, ry: 2.0, rotation: 0.0 }), None);
|
||||
let mut open = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
|
||||
open.closed = false;
|
||||
assert_eq!(recognise(&open), None);
|
||||
let mut bad = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
|
||||
bad.anchors[1].p.x = f64::NAN;
|
||||
assert_eq!(recognise(&bad), None);
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,56 @@ pub(crate) fn outline(
|
||||
};
|
||||
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));
|
||||
// The pixel at a corner of the shape lies on both of the corner's edges, and what it lacks of
|
||||
// the inside's colour is the doing of both: read as one edge's alone it dents the corner. The
|
||||
// crack of each edge on it takes the shift of the next crack along that edge instead (edges
|
||||
// of three pixels or more: a shorter one has no pixel clear of its corners).
|
||||
let shifts = {
|
||||
let mut shifts = shifts;
|
||||
for i in 0..n {
|
||||
let (b, e) = ((i + n - 1) % n, i);
|
||||
if step[b] || step[e] || turn(i) == 0 {
|
||||
continue;
|
||||
}
|
||||
let (before, edge) = (&edges[b], &edges[e]);
|
||||
if before.len >= 3 {
|
||||
shifts[before.first + before.len - 1] = shifts[before.first + before.len - 2];
|
||||
}
|
||||
if edge.len >= 3 {
|
||||
shifts[edge.first] = shifts[edge.first + 1];
|
||||
}
|
||||
}
|
||||
shifts
|
||||
};
|
||||
|
||||
// A sharp corner whose own pixel is less than half the inside's colour goes to the outside, and
|
||||
// the loop cuts the corner in two steps (the same, turned round, where the corner pixel of a
|
||||
// notch goes to the inside). Between two edges of three pixels or more, those two steps are
|
||||
// that corner: it lies where the two edges, each moved by its shift, meet. `notch[i]` is, for
|
||||
// the first of the two steps, the corner (`Some(Some(..))`), for the second nothing
|
||||
// (`Some(None)`), for any other edge `None`.
|
||||
let mut notch: Vec<Option<Option<Point>>> = vec![None; n];
|
||||
for i in 0..n {
|
||||
let (a, s1, s2, b) = ((i + n - 1) % n, i, (i + 1) % n, (i + 2) % n);
|
||||
let free = |e: usize| junctions[edges[e].first].is_none();
|
||||
if n >= 6
|
||||
&& step[s1]
|
||||
&& step[s2]
|
||||
&& edges[a].len >= 3
|
||||
&& edges[b].len >= 3
|
||||
&& edges[s1].dir == edges[b].dir
|
||||
&& edges[s2].dir == edges[a].dir
|
||||
&& free(s1)
|
||||
&& free(s2)
|
||||
&& free(b)
|
||||
{
|
||||
let (ea, eb) = (&edges[a], &edges[b]);
|
||||
// Edge `a` carried on one pixel further meets edge `b` carried back one.
|
||||
let meet = at(s1) + Vec2::new(f64::from(ea.dir.0), f64::from(ea.dir.1));
|
||||
notch[s1] = Some(Some(meet + outward(ea.dir) * shifts[ea.first + ea.len - 1] + outward(eb.dir) * shifts[eb.first]));
|
||||
notch[s2] = Some(None);
|
||||
}
|
||||
}
|
||||
|
||||
let mut pieces: Vec<Piece> = Vec::new();
|
||||
let start = first.unwrap_or(0);
|
||||
@@ -196,7 +246,10 @@ pub(crate) fn outline(
|
||||
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 {
|
||||
if !hard && let Some(sharp) = notch[i] {
|
||||
// A corner whose own pixel went to the other side: the corner, not the two steps.
|
||||
pts.extend(sharp);
|
||||
} else 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]);
|
||||
}
|
||||
}
|
||||
|
||||
+215
-2
@@ -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}");
|
||||
}
|
||||
@@ -608,7 +608,7 @@ fn create_strokes_survives_noise_and_tiny_images() {
|
||||
for noise in [0, 1, 4] {
|
||||
let p = TraceParams { strokes: true, fills, stroke_width: width, noise, ..TraceParams::default() };
|
||||
for t in trace(&img, &p).paths {
|
||||
assert!(t.stroke.is_none_or(|w| w.is_finite() && w >= 1.0), "{:?}", t.stroke);
|
||||
assert!(t.stroke.is_none_or(|w| w.is_finite() && w >= 0.25), "{:?}", t.stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1037,3 +1037,216 @@ fn abutting_regions_share_one_curve_along_their_edge() {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() {
|
||||
// 120 × 60, turned 1.5° about its centre.
|
||||
let (c, t) = (Point::new(100.3, 60.6), 1.5f64.to_radians());
|
||||
let img = soft(200, 120, |x, y| {
|
||||
let (dx, dy) = (x - c.x, y - c.y);
|
||||
(dx * t.cos() + dy * t.sin()).abs() <= 60.0 && (dy * t.cos() - dx * t.sin()).abs() <= 30.0
|
||||
});
|
||||
let sides = |snap: bool| {
|
||||
let res = trace(&img, &TraceParams { snap_curves_to_lines: snap, ..bw() });
|
||||
let sp = res.paths[0].path.subpaths[0].clone();
|
||||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||||
(0..4).map(|i| sp.segment(i)).map(|s| ((s.p3.x - s.p0.x).abs(), (s.p3.y - s.p0.y).abs())).collect::<Vec<_>>()
|
||||
};
|
||||
// Snapped, every side is exactly level or upright; left alone, it keeps its 1.5°.
|
||||
assert!(sides(true).iter().all(|(dx, dy)| *dx == 0.0 || *dy == 0.0), "{:?}", sides(true));
|
||||
assert!(sides(false).iter().all(|(dx, dy)| dx.min(*dy) > 0.5), "{:?}", sides(false));
|
||||
// And it is still the rectangle: 120 by 60 about the same centre.
|
||||
let b = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() }).paths[0].path.bounds().unwrap();
|
||||
assert!((b.width() - 120.0).abs() < 1.0 && (b.height() - 60.0).abs() < 1.0 && (b.center().x - c.x).abs() < 0.3, "{b:?}");
|
||||
// A side 5° off is no slip of the scanner: it stays.
|
||||
let t = 5f64.to_radians();
|
||||
let img = soft(200, 120, |x, y| {
|
||||
let (dx, dy) = (x - c.x, y - c.y);
|
||||
(dx * t.cos() + dy * t.sin()).abs() <= 60.0 && (dy * t.cos() - dx * t.sin()).abs() <= 30.0
|
||||
});
|
||||
let sp = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() }).paths[0].path.subpaths[0].clone();
|
||||
assert!(
|
||||
(0..sp.segment_count())
|
||||
.all(|i| (sp.segment(i).p3.y - sp.segment(i).p0.y).abs() > 1.0 && (sp.segment(i).p3.x - sp.segment(i).p0.x).abs() > 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straight_anti_aliased_line_is_two_anchors_in_its_middle_as_wide_as_its_ink() {
|
||||
// 2.6 px wide, from (20.3, 30.7) to (170.6, 52.2): a little off the pixel rows.
|
||||
let (a, b) = (Point::new(20.3, 30.7), Point::new(170.6, 52.2));
|
||||
let along_line = |p: Point| ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
|
||||
let img = soft(190, 80, |x, y| {
|
||||
let p = Point::new(x, y);
|
||||
p.distance(a + (b - a) * along_line(p)) <= 1.3
|
||||
});
|
||||
for noisy in [false, true] {
|
||||
// (With grain of ±6 levels too: the line stays two anchors.)
|
||||
let img = Raster::from_fn(190, 80, |x, y| {
|
||||
let v = f64::from(img.pixel(x, y)[0]) + if noisy { 6.0 * jitter(x, y, 3) } else { 0.0 };
|
||||
let v = v.round().clamp(0.0, 255.0) as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let res = trace(&img, &preset("Outline").unwrap());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
let (path, width) = (&res.paths[0].path, res.paths[0].stroke.unwrap());
|
||||
assert_eq!(path.anchor_count(), 2, "noisy {noisy}: {path:?}");
|
||||
assert!((width - 2.6).abs() < 0.15, "noisy {noisy}: {width} px wide");
|
||||
for end in [path.subpaths[0].anchors[0].p, path.subpaths[0].anchors[1].p] {
|
||||
// On the line through the true ends, to a tenth of a pixel.
|
||||
let off = (end - a).cross(b - a).abs() / (b - a).hypot();
|
||||
assert!(off < 0.1, "noisy {noisy}: an end {off} px off the line's middle");
|
||||
// And where the ink ends, less the half width its round cap adds (the line here has round ends).
|
||||
assert!(end.distance(a).min(end.distance(b)) < 0.6, "noisy {noisy}: an end at {end:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_is_as_wide_as_its_ink_not_a_whole_number_of_pixels() {
|
||||
// (Lines with a pixel that is all ink somewhere: a thinner one's ink is read against its own
|
||||
// darkest pixel, and comes out a little wide.)
|
||||
for want in [1.7, 2.4, 3.4] {
|
||||
let img = soft(120, 40, |x, y| (10.0..110.0).contains(&x) && (y - 20.3).abs() <= want / 2.0);
|
||||
let res = trace(&img, &preset("Outline").unwrap());
|
||||
let width = res.paths[0].stroke.unwrap();
|
||||
assert!((width - want).abs() < 0.08, "{width} for a line {want} px wide");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anti_aliased_rectangle_along_the_pixel_grid_is_four_anchors() {
|
||||
// Its sides lie between pixel columns and rows: its corner pixels are part of two sides each.
|
||||
let img = soft(140, 90, |x, y| (20.4..120.7).contains(&x) && (15.3..70.6).contains(&y));
|
||||
let res = trace(&img, &bw());
|
||||
let sp = &res.paths[0].path.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||||
let b = res.paths[0].path.bounds().unwrap();
|
||||
// (To the eighth of a pixel the test picture is drawn to.)
|
||||
assert!((b.x0 - 20.4).abs() < 0.07 && (b.x1 - 120.7).abs() < 0.07 && (b.y0 - 15.3).abs() < 0.07 && (b.y1 - 70.6).abs() < 0.07, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_come_out_as_shapes_when_asked_for() {
|
||||
// A disc, a square along the pixel grid and a rectangle turned 20°, anti-aliased.
|
||||
let turn = 20f64.to_radians();
|
||||
let turned = |x: f64, y: f64| {
|
||||
let (dx, dy) = (x - 180.4, y - 45.3);
|
||||
(dx * turn.cos() + dy * turn.sin()).abs() <= 30.0 && (dy * turn.cos() - dx * turn.sin()).abs() <= 18.0
|
||||
};
|
||||
let img = soft(220, 90, |x, y| {
|
||||
Point::new(x, y).distance(Point::new(40.3, 44.6)) <= 25.0 || ((80.4..130.7).contains(&x) && (20.3..70.6).contains(&y)) || turned(x, y)
|
||||
});
|
||||
let plain = trace(&img, &bw());
|
||||
assert!(plain.paths.iter().all(|p| p.shape.is_none()));
|
||||
let res = trace(&img, &TraceParams { shapes: true, ..bw() });
|
||||
assert_eq!(res.paths.len(), 3);
|
||||
let mut found = (false, false, false);
|
||||
for p in &res.paths {
|
||||
// The path is the shape's own outline.
|
||||
assert_eq!(Some(&p.path), p.shape.map(|s| s.path()).as_ref());
|
||||
match p.shape {
|
||||
Some(TracedShape::Ellipse { center, rx, ry, .. }) => {
|
||||
found.0 = center.distance(Point::new(40.3, 44.6)) < 0.15 && (rx - 25.0).abs() < 0.15 && rx == ry
|
||||
}
|
||||
Some(TracedShape::Rectangle { center, width, height, radius: 0.0, rotation }) if center.x < 150.0 => {
|
||||
found.1 = center.distance(Point::new(105.55, 45.45)) < 0.15
|
||||
&& (width - 50.3).abs() < 0.25
|
||||
&& (height - 50.3).abs() < 0.25
|
||||
&& rotation.abs() < 0.3
|
||||
}
|
||||
Some(TracedShape::Rectangle { center, width, height, radius: 0.0, rotation }) => {
|
||||
found.2 = center.distance(Point::new(180.4, 45.3)) < 0.2
|
||||
&& (width - 60.0).abs() < 0.3
|
||||
&& (height - 36.0).abs() < 0.3
|
||||
&& (rotation - 20.0).abs() < 0.3
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(found, (true, true, true));
|
||||
// A letter is no shape, and a ring is not a disc.
|
||||
let ring = soft(100, 100, |x, y| (20.0..40.0).contains(&Point::new(x, y).distance(Point::new(50.0, 50.0))));
|
||||
assert!(trace(&ring, &TraceParams { shapes: true, ..bw() }).paths.iter().all(|p| p.shape.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corner_whose_pixel_is_mostly_outside_is_still_a_corner() {
|
||||
// Each corner pixel is under half covered (0.6 × 0.7 and less) and traces as outside.
|
||||
let img = soft(140, 90, |x, y| (20.4..120.6).contains(&x) && (15.3..70.7).contains(&y));
|
||||
let res = trace(&img, &bw());
|
||||
let sp = &res.paths[0].path.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||||
let b = res.paths[0].path.bounds().unwrap();
|
||||
assert!((b.x0 - 20.4).abs() < 0.07 && (b.x1 - 120.6).abs() < 0.07 && (b.y0 - 15.3).abs() < 0.07 && (b.y1 - 70.7).abs() < 0.07, "{b:?}");
|
||||
}
|
||||
|
||||
/// A circle without anti-aliasing is still a circle, not an ellipse a little wider than tall.
|
||||
#[test]
|
||||
fn a_hard_edged_circle_is_a_circle() {
|
||||
let img = Raster::from_fn(100, 100, |x, y| {
|
||||
let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0);
|
||||
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
|
||||
});
|
||||
let res = trace(&img, &TraceParams { shapes: true, ignore_white: true, ..Default::default() });
|
||||
let [TracedPath { shape: Some(TracedShape::Ellipse { center, rx, ry, .. }), .. }] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
|
||||
assert!(rx == ry && (rx - 30.0).abs() < 0.3 && center.distance(Point::new(50.0, 50.0)) < 0.1, "{center:?} {rx} {ry}");
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! sizes) and two continuous-tone images computed per pixel.
|
||||
|
||||
use kurbo::{Affine, BezPath, Circle, Ellipse, Point, RoundedRect, Shape};
|
||||
use vectorcraft_trace::Raster;
|
||||
use vectorcraft_trace::{Raster, TracedShape};
|
||||
|
||||
use crate::raster::{self, noise};
|
||||
|
||||
@@ -15,13 +15,20 @@ pub enum Class {
|
||||
Poly,
|
||||
/// Thin lines, 0.5 to 5 units wide.
|
||||
Lines,
|
||||
/// Straight-edged shapes and lines a degree or two off level and upright, as a scan or a
|
||||
/// screenshot taken a little askew has them.
|
||||
Askew,
|
||||
/// Plain shapes apart from each other: a circle, an ellipse, rectangles, a regular polygon.
|
||||
Plain,
|
||||
/// Flat art in several colours, with regions that touch.
|
||||
Flat,
|
||||
/// Continuous tone: a gradient and a photo-like image.
|
||||
Tone,
|
||||
/// Linear gradients whose line and stops are known: one over the whole image, one in a disc.
|
||||
Ramp,
|
||||
}
|
||||
|
||||
pub const CLASSES: [Class; 5] = [Class::Shape, Class::Poly, Class::Lines, Class::Flat, Class::Tone];
|
||||
pub const CLASSES: [Class; 8] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone, Class::Ramp];
|
||||
|
||||
impl Class {
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -29,8 +36,11 @@ impl Class {
|
||||
Class::Shape => "curved shapes",
|
||||
Class::Poly => "polygons",
|
||||
Class::Lines => "thin lines",
|
||||
Class::Askew => "askew shapes",
|
||||
Class::Plain => "plain shapes",
|
||||
Class::Flat => "flat colour art",
|
||||
Class::Tone => "gradient, photo",
|
||||
Class::Ramp => "linear gradients",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,9 +88,69 @@ pub struct Input {
|
||||
pub anchors: usize,
|
||||
/// Its sharp corners.
|
||||
pub corners: Vec<Point>,
|
||||
/// The plain shapes it is made of, where it is made of them.
|
||||
pub plain: Vec<TracedShape>,
|
||||
/// Its lines, where it is lines: each one's two ends and its width.
|
||||
pub strokes: Vec<(Point, Point, f64)>,
|
||||
/// The linear gradient it is, where it is one.
|
||||
pub ramp: Option<Ramp>,
|
||||
tone: Option<Tone>,
|
||||
}
|
||||
|
||||
/// A linear gradient: its line (256-unit coordinates) and its stops along it (0 to 1).
|
||||
#[derive(Clone)]
|
||||
pub struct Ramp {
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
pub stops: Vec<(f64, [f64; 3])>,
|
||||
}
|
||||
|
||||
impl Ramp {
|
||||
pub fn color_at(&self, p: Point) -> [f64; 3] {
|
||||
let d = self.end - self.start;
|
||||
let t = ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0);
|
||||
let after = self.stops.iter().position(|s| s.0 >= t).unwrap().max(1);
|
||||
let (a, b) = (self.stops[after - 1], self.stops[after]);
|
||||
let k = (t - a.0) / (b.0 - a.0);
|
||||
[0, 1, 2].map(|c| a.1[c] + (b.1[c] - a.1[c]) * k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dark blue to yellow across the whole image, 25° down from level.
|
||||
fn two_stop_ramp() -> Ramp {
|
||||
let dir = kurbo::Vec2::from_angle(25f64.to_radians());
|
||||
let reach = kurbo::Vec2::new(128.0, 128.0).dot(dir);
|
||||
let centre = Point::new(128.0, 128.0);
|
||||
Ramp { start: centre - dir * reach, end: centre + dir * reach, stops: vec![(0.0, [24.0, 40.0, 150.0]), (1.0, [250.0, 215.0, 60.0])] }
|
||||
}
|
||||
|
||||
/// Red through white to green across a disc, 110° from level.
|
||||
fn three_stop_ramp() -> Ramp {
|
||||
let dir = kurbo::Vec2::from_angle(110f64.to_radians());
|
||||
let centre = Point::new(128.0, 128.0);
|
||||
Ramp {
|
||||
start: centre - dir * DISC,
|
||||
end: centre + dir * DISC,
|
||||
stops: vec![(0.0, [200.0, 30.0, 40.0]), (0.4, [245.0, 240.0, 230.0]), (1.0, [20.0, 120.0, 70.0])],
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius of the disc the three-stop gradient fills.
|
||||
const DISC: f64 = 90.0;
|
||||
|
||||
fn two_stop(u: f64, v: f64, _: u32, _: u32) -> [f64; 3] {
|
||||
two_stop_ramp().color_at(Point::new(u * 256.0, v * 256.0))
|
||||
}
|
||||
|
||||
fn three_stop(u: f64, v: f64, x: u32, _: u32) -> [f64; 3] {
|
||||
let p = Point::new(u * 256.0, v * 256.0);
|
||||
let inside = three_stop_ramp().color_at(p);
|
||||
// The disc's edge, anti-aliased: how much of the pixel (256 / size units wide) is in it.
|
||||
let pixel = 256.0 * u / (f64::from(x) + 0.5);
|
||||
let cover = (0.5 + (DISC - p.distance(Point::new(128.0, 128.0))) / pixel).clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|c| 255.0 + (inside[c] - 255.0) * cover)
|
||||
}
|
||||
|
||||
const WHITE: [u8; 3] = [255, 255, 255];
|
||||
const INK: [u8; 3] = [0, 0, 0];
|
||||
|
||||
@@ -166,9 +236,23 @@ pub fn inputs() -> Vec<Input> {
|
||||
bg: WHITE,
|
||||
anchors,
|
||||
corners,
|
||||
strokes: vec![],
|
||||
plain: vec![],
|
||||
ramp: None,
|
||||
tone: None,
|
||||
};
|
||||
let tone = |name, f: Tone| Input { name, class: Class::Tone, shapes: vec![], bg: WHITE, anchors: 0, corners: vec![], tone: Some(f) };
|
||||
let tone = |name, f: Tone| Input {
|
||||
name,
|
||||
class: Class::Tone,
|
||||
shapes: vec![],
|
||||
bg: WHITE,
|
||||
anchors: 0,
|
||||
corners: vec![],
|
||||
strokes: vec![],
|
||||
plain: vec![],
|
||||
ramp: None,
|
||||
tone: Some(f),
|
||||
};
|
||||
let ink = |p: BezPath| vec![(p, INK)];
|
||||
|
||||
let mut ellipses = Ellipse::new((90.3, 99.6), (60.0, 30.0), 0.0).to_path(1e-3);
|
||||
@@ -208,13 +292,51 @@ pub fn inputs() -> Vec<Input> {
|
||||
|
||||
// Five lines 200 long, 0.5 to 5 wide, at small angles to the pixel grid.
|
||||
let mut lines = BezPath::new();
|
||||
let mut strokes = vec![];
|
||||
for (i, (deg, width)) in [(0.0f64, 0.5), (6.0, 1.0), (-9.0, 2.0), (12.0, 3.0), (-4.0, 5.0)].into_iter().enumerate() {
|
||||
let (hw, hl) = (width / 2.0, 100.0);
|
||||
let mut strip = poly(&[(-hl, -hw), (hl, -hw), (hl, hw), (-hl, hw)]);
|
||||
strip.apply_affine(Affine::translate((128.3, 36.4 + 46.0 * i as f64)) * Affine::rotate(deg.to_radians()));
|
||||
let place = Affine::translate((128.3, 36.4 + 46.0 * i as f64)) * Affine::rotate(deg.to_radians());
|
||||
strip.apply_affine(place);
|
||||
strokes.push((place * Point::new(-hl, 0.0), place * Point::new(hl, 0.0), width));
|
||||
lines.extend(strip);
|
||||
}
|
||||
|
||||
// A rectangle 1.5° off level, a plus sign 1° off the other way, and two lines 1° and 2° off.
|
||||
let turned = |p: BezPath, deg: f64, about: (f64, f64)| Affine::rotate_about(deg.to_radians(), Point::new(about.0, about.1)) * p;
|
||||
let plus = [
|
||||
(150.0, 150.0),
|
||||
(170.0, 150.0),
|
||||
(170.0, 180.0),
|
||||
(200.0, 180.0),
|
||||
(200.0, 200.0),
|
||||
(170.0, 200.0),
|
||||
(170.0, 230.0),
|
||||
(150.0, 230.0),
|
||||
(150.0, 200.0),
|
||||
(120.0, 200.0),
|
||||
(120.0, 180.0),
|
||||
(150.0, 180.0),
|
||||
];
|
||||
let mut askew = turned(poly(&[(40.3, 30.6), (200.7, 30.6), (200.7, 110.4), (40.3, 110.4)]), 1.5, (120.0, 70.0));
|
||||
askew.extend(turned(poly(&plus), -1.0, (160.0, 190.0)));
|
||||
askew.extend(turned(poly(&[(20.0, 150.3), (100.0, 150.3), (100.0, 153.3), (20.0, 153.3)]), 1.0, (60.0, 152.0)));
|
||||
askew.extend(turned(poly(&[(60.4, 170.0), (62.4, 170.0), (62.4, 240.0), (60.4, 240.0)]), 2.0, (61.0, 205.0)));
|
||||
|
||||
// A circle, a rectangle, a square, a rounded rectangle, an ellipse and a hexagon.
|
||||
let plain = vec![
|
||||
TracedShape::Ellipse { center: Point::new(50.3, 50.6), rx: 30.0, ry: 30.0, rotation: 0.0 },
|
||||
TracedShape::Rectangle { center: Point::new(170.4, 50.3), width: 110.0, height: 60.0, radius: 0.0, rotation: 0.0 },
|
||||
TracedShape::Rectangle { center: Point::new(50.6, 140.4), width: 60.0, height: 60.0, radius: 0.0, rotation: 0.0 },
|
||||
TracedShape::Rectangle { center: Point::new(170.3, 140.6), width: 100.0, height: 50.0, radius: 12.0, rotation: 0.0 },
|
||||
TracedShape::Ellipse { center: Point::new(70.4, 220.3), rx: 45.0, ry: 18.0, rotation: 0.0 },
|
||||
TracedShape::Polygon { center: Point::new(190.6, 215.4), radius: 30.0, sides: 6, rotation: 10.0 },
|
||||
];
|
||||
let mut plain_art = BezPath::new();
|
||||
for shape in &plain {
|
||||
plain_art.extend(shape.path().to_bezpath());
|
||||
}
|
||||
|
||||
let centre = Point::new(128.4, 128.3);
|
||||
let touching = vec![(half_disc(centre, 90.0, true), [200, 40, 50]), (half_disc(centre, 90.0, false), [30, 90, 200])];
|
||||
let bar = [(60.3, 180.4), (196.6, 180.4), (196.6, 206.7), (60.3, 206.7)];
|
||||
@@ -225,7 +347,7 @@ pub fn inputs() -> Vec<Input> {
|
||||
(poly(&bar), [40, 160, 90]),
|
||||
];
|
||||
|
||||
vec![
|
||||
let mut all = vec![
|
||||
art("circle", Class::Shape, ink(Circle::new((128.3, 127.6), 80.4).to_path(1e-3)), 4, vec![]),
|
||||
art("ellipses", Class::Shape, ink(ellipses), 8, vec![]),
|
||||
art("badge with a hole", Class::Shape, ink(badge), 12, vec![]),
|
||||
@@ -234,11 +356,22 @@ pub fn inputs() -> Vec<Input> {
|
||||
art("star", Class::Poly, ink(poly(&star)), 10, points(&star)),
|
||||
art("letter A", Class::Poly, ink(with_holes(poly(&a_outer), &[poly(&a_counter)])), 10, points(&[&a_outer[..], &a_counter].concat())),
|
||||
art("lines", Class::Lines, ink(lines), 20, vec![]),
|
||||
art("askew shapes and lines", Class::Askew, ink(askew), 24, vec![]),
|
||||
art("plain shapes", Class::Plain, ink(plain_art), 30, vec![]),
|
||||
art("two touching halves", Class::Flat, touching, 6, vec![(centre.x, centre.y - 90.0).into(), (centre.x, centre.y + 90.0).into()]),
|
||||
art("four-colour badge", Class::Flat, multicolor, 20, points(&bar)),
|
||||
tone("gradient", gradient),
|
||||
tone("photo-like", photo),
|
||||
]
|
||||
Input { class: Class::Ramp, ramp: Some(two_stop_ramp()), ..tone("two-stop gradient", two_stop) },
|
||||
Input { class: Class::Ramp, ramp: Some(three_stop_ramp()), ..tone("three-stop gradient in a disc", three_stop) },
|
||||
];
|
||||
if let Some(input) = all.iter_mut().find(|i| i.name == "plain shapes") {
|
||||
input.plain = plain;
|
||||
}
|
||||
if let Some(input) = all.iter_mut().find(|i| i.name == "lines") {
|
||||
input.strokes = strokes;
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
impl Input {
|
||||
@@ -268,6 +401,16 @@ impl Input {
|
||||
let k = Affine::scale(f64::from(size) / 256.0);
|
||||
self.shapes.iter().map(|(p, c)| (k * p.clone(), *c)).collect()
|
||||
}
|
||||
/// Its lines in the pixel coordinates of a `size`-pixel drawing.
|
||||
pub fn scaled_strokes(&self, size: u32) -> Vec<(Point, Point, f64)> {
|
||||
let k = f64::from(size) / 256.0;
|
||||
self.strokes.iter().map(|(a, b, w)| ((a.to_vec2() * k).to_point(), (b.to_vec2() * k).to_point(), w * k)).collect()
|
||||
}
|
||||
/// Its plain shapes' outlines in the pixel coordinates of a `size`-pixel drawing.
|
||||
pub fn scaled_plain(&self, size: u32) -> Vec<BezPath> {
|
||||
let k = Affine::scale(f64::from(size) / 256.0);
|
||||
self.plain.iter().map(|s| k * s.path().to_bezpath()).collect()
|
||||
}
|
||||
pub fn scaled_corners(&self, size: u32) -> Vec<Point> {
|
||||
self.corners.iter().map(|p| (p.to_vec2() * (f64::from(size) / 256.0)).to_point()).collect()
|
||||
}
|
||||
|
||||
@@ -65,14 +65,20 @@ static ALLOC: Counting = Counting;
|
||||
/// Whether `input` is what `preset` is meant for.
|
||||
fn target(preset: &str, input: &Input) -> bool {
|
||||
let classes: &[Class] = match preset {
|
||||
"High Fidelity Photo" | "Low Fidelity Photo" | "Shades of Gray" => &[Class::Tone],
|
||||
"High Fidelity Photo"
|
||||
| "Low Fidelity Photo"
|
||||
| "Shades of Gray"
|
||||
| "Shades of Gray with Auto Grouping"
|
||||
| "High Fidelity Photo with Auto Grouping" => &[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" | "6 Colors with Auto Grouping" => &[Class::Flat],
|
||||
"Default with Shapes" | "6 Colors with Shapes" => &[Class::Plain],
|
||||
name if name.ends_with("Gradients") => &[Class::Ramp],
|
||||
"Sketched Art" => &[Class::Shape, Class::Lines],
|
||||
"Silhouettes" => &[Class::Shape],
|
||||
"Line Art" => &[Class::Lines],
|
||||
"Technical Drawing" => &[Class::Lines, Class::Poly],
|
||||
"Line Art" | "Outline" => &[Class::Lines],
|
||||
"Technical Drawing" => &[Class::Lines, Class::Poly, Class::Askew],
|
||||
_ => &[Class::Shape, Class::Poly],
|
||||
};
|
||||
classes.contains(&input.class)
|
||||
@@ -134,9 +140,16 @@ fn svg_trace(svg: &str) -> TraceResult {
|
||||
_ => panic!("unread transform {t}"),
|
||||
});
|
||||
}
|
||||
Some(TracedPath { path: vectorcraft_geom::PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: None })
|
||||
Some(TracedPath {
|
||||
path: vectorcraft_geom::PathData::from_bezpath(&path),
|
||||
color: [0; 3],
|
||||
pixels: 0,
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
})
|
||||
});
|
||||
TraceResult { paths: paths.collect(), palette: vec![[0; 3]] }
|
||||
TraceResult { paths: paths.collect(), palette: vec![[0; 3]], ..Default::default() }
|
||||
}
|
||||
|
||||
fn run_all(presets: &[(&'static str, TraceParams)], drawn: &[Drawing]) -> Vec<Run> {
|
||||
@@ -172,12 +185,12 @@ fn stat(runs: &[&Metrics], field: impl Fn(&Metrics) -> f64, max: bool, digits: u
|
||||
if x.is_finite() { format!("{:.digits$}", x + 0.0) } else { "all".into() }
|
||||
}
|
||||
|
||||
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
|
||||
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | grouped % | gradient paths | found % | angle off | ramp off |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
|
||||
|
||||
fn row(runs: &[&Run]) -> String {
|
||||
let m: Vec<&Metrics> = runs.iter().filter_map(|r| r.metrics.as_ref()).collect();
|
||||
if m.is_empty() {
|
||||
return format!("| refused |{}", " |".repeat(16));
|
||||
return format!("| refused |{}", " |".repeat(26));
|
||||
}
|
||||
let cells = [
|
||||
stat(&m, |m| m.delta, false, 2),
|
||||
@@ -189,6 +202,7 @@ fn row(runs: &[&Run]) -> String {
|
||||
stat(&m, |m| m.anchors, false, 0),
|
||||
stat(&m, |m| m.anchor_ratio, false, 2),
|
||||
stat(&m, |m| m.lines, false, 0),
|
||||
stat(&m, |m| m.upright, false, 0),
|
||||
stat(&m, |m| m.corners, false, 0),
|
||||
stat(&m, |m| m.kinks, false, 1),
|
||||
stat(&m, |m| m.slivers, false, 1),
|
||||
@@ -197,6 +211,15 @@ fn row(runs: &[&Run]) -> String {
|
||||
stat(&m, |m| m.overlap, false, 1),
|
||||
stat(&m, |m| m.specks, false, 1),
|
||||
stat(&m, |m| m.outside, true, 1),
|
||||
stat(&m, |m| m.centre_off, false, 3),
|
||||
stat(&m, |m| m.width_off, false, 2),
|
||||
stat(&m, |m| m.stroke_anchors, false, 1),
|
||||
stat(&m, |m| m.shapes, false, 0),
|
||||
stat(&m, |m| m.grouped, false, 0),
|
||||
stat(&m, |m| m.gradients, false, 1),
|
||||
stat(&m, |m| m.ramp_found, false, 0),
|
||||
stat(&m, |m| m.ramp_angle, true, 1),
|
||||
stat(&m, |m| m.ramp_off, true, 0),
|
||||
];
|
||||
format!("| {} |", cells.join(" | "))
|
||||
}
|
||||
@@ -220,10 +243,30 @@ fn cost(img: &Raster, params: &TraceParams) -> String {
|
||||
}
|
||||
|
||||
fn report() -> String {
|
||||
let presets = presets();
|
||||
// The built-in presets, and some of them with the Shapes or the Auto Grouping option on.
|
||||
let mut presets = presets();
|
||||
for (name, base) in [("Default with Shapes", "Default"), ("6 Colors with Shapes", "6 Colors")] {
|
||||
presets.push((name, TraceParams { shapes: true, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
for (name, base) in [
|
||||
("6 Colors with Auto Grouping", "6 Colors"),
|
||||
("Shades of Gray with Auto Grouping", "Shades of Gray"),
|
||||
("High Fidelity Photo with Auto Grouping", "High Fidelity Photo"),
|
||||
] {
|
||||
presets.push((name, TraceParams { auto_grouping: true, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
// Gradients: on the inputs that are linear gradients, with the option and without it.
|
||||
for (name, base, gradients) in [
|
||||
("6 Colors, no Gradients", "6 Colors", false),
|
||||
("6 Colors with Gradients", "6 Colors", true),
|
||||
("High Fidelity Photo, no Gradients", "High Fidelity Photo", false),
|
||||
("High Fidelity Photo with Gradients", "High Fidelity Photo", true),
|
||||
] {
|
||||
presets.push((name, TraceParams { gradients, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
let inputs = inputs();
|
||||
let drawn = draw_all(&inputs);
|
||||
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines);
|
||||
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew | Class::Plain);
|
||||
if let Ok(dir) = std::env::var("TRACE_QUALITY_IMAGES") {
|
||||
for (input, size, variant, img) in &drawn {
|
||||
std::fs::write(format!("{dir}/{}.png", stem(input, *size, *variant)), img.encode_png()).unwrap();
|
||||
@@ -270,7 +313,7 @@ fn report() -> String {
|
||||
.collect();
|
||||
let _ = write!(md, "\n## 4. Yardstick\n\n{} traces by another tracer, measured the same way.\n\n| inputs | variant ", theirs.len());
|
||||
md += HEADER.replacen("\n|", "\n|:--|:--|", 1).as_str();
|
||||
for class in [Class::Shape, Class::Poly, Class::Lines] {
|
||||
for class in [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain] {
|
||||
for variant in VARIANTS {
|
||||
let rows: Vec<&Run> = theirs.iter().filter(|r| r.class == class && r.variant == variant).collect();
|
||||
let _ = writeln!(md, "| {} | {} {}", class.name(), variant.name(), row(&rows));
|
||||
@@ -304,6 +347,15 @@ fn report() -> String {
|
||||
fn quality_report() {
|
||||
let md = report();
|
||||
println!("{md}");
|
||||
// Gradients finds none in art that is flat colour, however it is degraded.
|
||||
let inputs = inputs();
|
||||
for (name, _) in presets().iter().filter(|(_, p)| p.mode != vectorcraft_trace::Mode::BlackAndWhite && p.mode != vectorcraft_trace::Mode::Logo) {
|
||||
let params = TraceParams { gradients: true, ..vectorcraft_trace::preset(name).unwrap() };
|
||||
for (input, size, variant, img) in draw_all(&inputs).iter().filter(|d| !matches!(d.0.class, Class::Tone | Class::Ramp)) {
|
||||
let found = trace_within(img, ¶ms, MAX_ANCHORS).map_or(0, |res| res.paths.iter().filter(|p| p.gradient.is_some()).count());
|
||||
assert_eq!(found, 0, "{name} with Gradients on {} at {size} px, {variant:?}", input.name);
|
||||
}
|
||||
}
|
||||
if let Ok(path) = std::env::var("TRACE_QUALITY_REPORT") {
|
||||
std::fs::write(path, md).unwrap();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ use std::collections::HashMap;
|
||||
|
||||
use kurbo::{BezPath, ParamCurveArclen, Point, Shape, Stroke, StrokeOpts};
|
||||
use vectorcraft_geom::SubPath;
|
||||
use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, TracedPath};
|
||||
use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, TracedGradient, TracedPath};
|
||||
|
||||
use crate::inputs::{Class, Input, Variant};
|
||||
use crate::raster::{coverage_into, polylines, render};
|
||||
use crate::raster::{coverage_into, polylines, render_painted};
|
||||
|
||||
/// sRGB to Oklab (Björn Ottosson, "A perceptual color space for image processing", 2020).
|
||||
pub fn oklab(c: [u8; 3]) -> [f64; 3] {
|
||||
@@ -175,6 +175,8 @@ pub struct Metrics {
|
||||
pub anchor_ratio: f64,
|
||||
/// Share (%) of the outline length made of straight segments.
|
||||
pub lines: f64,
|
||||
/// Share (%) of the outline length that is exactly level or upright.
|
||||
pub upright: f64,
|
||||
/// Share (%) of the art's sharp corners that have a traced corner within 1.5 px.
|
||||
pub corners: f64,
|
||||
/// Anchors where the path turns by 10° or more away from any true corner.
|
||||
@@ -185,17 +187,79 @@ pub struct Metrics {
|
||||
/// Area (px) no path covers, and area covered twice.
|
||||
pub gap: f64,
|
||||
pub overlap: f64,
|
||||
/// Stroked centre lines: their mean distance (px) from the true line's centre line, the mean
|
||||
/// error (px) of their widths, and anchors per stroked path.
|
||||
pub centre_off: f64,
|
||||
pub width_off: f64,
|
||||
pub stroke_anchors: f64,
|
||||
/// Share (%) of the art's plain shapes that came out as a shape lying within 0.6 px of it.
|
||||
pub shapes: f64,
|
||||
/// Auto Grouping: share (%) of the paths that lie inside another.
|
||||
pub grouped: f64,
|
||||
/// Gradients: paths filled with one; and where the input is a known gradient, whether one was
|
||||
/// found (%), the angle (°) between its line and the largest traced gradient's, and the largest
|
||||
/// difference (levels of a channel) between their colours along the true line.
|
||||
pub gradients: f64,
|
||||
pub ramp_found: f64,
|
||||
pub ramp_angle: f64,
|
||||
pub ramp_off: f64,
|
||||
/// Paths under 6 px of area.
|
||||
pub specks: f64,
|
||||
/// How far (px) the trace reaches outside the image: a stray curve.
|
||||
pub outside: f64,
|
||||
}
|
||||
|
||||
/// The order a grouped result is painted in: each path, then those inside it.
|
||||
fn draw_order(res: &TraceResult) -> Vec<usize> {
|
||||
let mut inside = vec![vec![]; res.paths.len()];
|
||||
let mut stack = vec![];
|
||||
for i in (0..res.paths.len()).rev() {
|
||||
match res.parents.get(i).copied().flatten() {
|
||||
Some(p) => inside[p].insert(0, i),
|
||||
None => stack.push(i),
|
||||
}
|
||||
}
|
||||
let mut order = vec![];
|
||||
while let Some(i) = stack.pop() {
|
||||
order.push(i);
|
||||
stack.extend(inside[i].iter().rev());
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// Measure `res`, the trace of `src` (`input` drawn `size` px square as `variant`) with `params`.
|
||||
pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params: &TraceParams, res: &TraceResult) -> Metrics {
|
||||
let n = size as usize;
|
||||
let shapes: Vec<(BezPath, [u8; 3])> = res.paths.iter().map(|tp| (painted(tp), tp.color)).collect();
|
||||
let (delta_mean, edge_delta) = fidelity(src, &render(&shapes, [255; 3], n, n));
|
||||
// Painted as the result is grouped: each path, then those inside it.
|
||||
let drawn: Vec<(BezPath, [u8; 3], Option<&TracedGradient>)> =
|
||||
draw_order(res).into_iter().map(|i| (shapes[i].0.clone(), shapes[i].1, res.paths[i].gradient.as_ref())).collect();
|
||||
let (delta_mean, edge_delta) = fidelity(src, &render_painted(&drawn, [255; 3], n, n));
|
||||
// Gradients: how many paths have one; and the largest of them against the gradient the input
|
||||
// is: the angle between their lines, and the largest difference of a channel between their
|
||||
// colours at 17 points along the true line.
|
||||
let gradients = res.paths.iter().filter(|tp| tp.gradient.is_some()).count() as f64;
|
||||
let (mut ramp_found, mut ramp_angle, mut ramp_off) = (f64::NAN, f64::NAN, f64::NAN);
|
||||
if let Some(ramp) = &input.ramp {
|
||||
let k = f64::from(size) / 256.0;
|
||||
let found = res.paths.iter().filter(|tp| tp.gradient.is_some()).max_by_key(|tp| tp.pixels).and_then(|tp| tp.gradient.as_ref());
|
||||
ramp_found = if found.is_some() { 100.0 } else { 0.0 };
|
||||
if let Some(g) = found {
|
||||
let (truth, own) = (ramp.end - ramp.start, g.end - g.start);
|
||||
let between = (truth.atan2() - own.atan2()).to_degrees().rem_euclid(180.0);
|
||||
ramp_angle = between.min(180.0 - between);
|
||||
ramp_off = (0..=16)
|
||||
.map(|j| {
|
||||
let p = ramp.start + truth * (f64::from(j) / 16.0);
|
||||
// (The true colour as this variant shows it: a scan's are its own.)
|
||||
let a = variant.color(ramp.color_at(p).map(|v| v.round() as u8)).map(f64::from);
|
||||
let b = g.color_at((p.to_vec2() * k).to_point());
|
||||
(0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f64::max)
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
}
|
||||
}
|
||||
let grouped = if res.parents.is_empty() { f64::NAN } else { 100.0 * res.parents.iter().flatten().count() as f64 / res.paths.len().max(1) as f64 };
|
||||
|
||||
// The background's frame: a path as large as the image.
|
||||
let is_frame = |p: &BezPath| {
|
||||
@@ -207,7 +271,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
|
||||
let nan = f64::NAN;
|
||||
let (mut geo_mean, mut geo_max, mut miss_max, mut corners, mut kinks, mut slivers, mut sliver_area) = (nan, nan, nan, nan, nan, nan, nan);
|
||||
if input.class != Class::Tone {
|
||||
if !matches!(input.class, Class::Tone | Class::Ramp) {
|
||||
let truth_shapes = input.scaled(size);
|
||||
let truth = Outline::new(truth_shapes.iter().map(|(p, _)| p), None);
|
||||
let traced = Outline::new(shapes.iter().map(|(p, _)| p), Some(f64::from(size)));
|
||||
@@ -224,7 +288,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
let kept = true_corners.iter().filter(|c| turns.iter().any(|(p, t)| *t >= 20.0 && p.distance(**c) <= 1.5)).count();
|
||||
corners = 100.0 * kept as f64 / true_corners.len() as f64;
|
||||
}
|
||||
if input.class != Class::Lines {
|
||||
if !matches!(input.class, Class::Lines | Class::Askew | Class::Plain) {
|
||||
kinks = turns.iter().filter(|(p, t)| *t >= 10.0 && true_corners.iter().all(|c| p.distance(*c) > 2.0)).count() as f64;
|
||||
}
|
||||
if params.mode != Mode::BlackAndWhite {
|
||||
@@ -234,7 +298,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
}
|
||||
}
|
||||
|
||||
let (mut length, mut straight) = (0.0, 0.0);
|
||||
let (mut length, mut straight, mut upright) = (0.0, 0.0, 0.0);
|
||||
for sp in art.iter().flat_map(|(tp, _)| &tp.path.subpaths) {
|
||||
for i in 0..sp.segment_count() {
|
||||
let len = sp.segment(i).arclen(0.05);
|
||||
@@ -243,6 +307,9 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
let c = sp.segment(i);
|
||||
if seg_dist(c.p1, c.p0, c.p3) < 0.02 && seg_dist(c.p2, c.p0, c.p3) < 0.02 {
|
||||
straight += len;
|
||||
if (c.p0.x - c.p3.x).abs() < 1e-9 || (c.p0.y - c.p3.y).abs() < 1e-9 {
|
||||
upright += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,7 +332,55 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
}
|
||||
}
|
||||
|
||||
// Stroked paths against the lines they trace: each against the true line nearest its middle.
|
||||
let (mut centre_off, mut width_off, mut stroke_anchors) = (nan, nan, nan);
|
||||
let truth = input.scaled_strokes(size);
|
||||
let stroked: Vec<&TracedPath> = res.paths.iter().filter(|tp| tp.stroke.is_some()).collect();
|
||||
if !truth.is_empty() && !stroked.is_empty() {
|
||||
let (mut off, mut samples, mut width) = (0.0, 0.0, 0.0);
|
||||
for tp in &stroked {
|
||||
let line = Outline::new([&tp.path.to_bezpath()], None);
|
||||
let Some(middle) = line.segs.get(line.segs.len() / 2).map(|s| s.0.midpoint(s.1)) else { continue };
|
||||
let Some((a, b, w)) = truth.iter().copied().min_by(|p, q| seg_dist(middle, p.0, p.1).total_cmp(&seg_dist(middle, q.0, q.1))) else {
|
||||
continue;
|
||||
};
|
||||
for s in &line.segs {
|
||||
// The distance to the line through the true ends: a centre line may be shorter or
|
||||
// longer than the true one by how its caps are drawn.
|
||||
let d = b - a;
|
||||
off += (s.0.midpoint(s.1) - a).cross(d).abs() / d.hypot().max(1e-9);
|
||||
samples += 1.0;
|
||||
}
|
||||
width += (tp.stroke.unwrap_or(0.0) - w).abs();
|
||||
}
|
||||
if samples > 0.0 {
|
||||
(centre_off, width_off) = (off / samples, width / stroked.len() as f64);
|
||||
stroke_anchors = stroked.iter().map(|tp| tp.path.anchor_count()).sum::<usize>() as f64 / stroked.len() as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain shapes: each is found when some path that is a shape lies on it.
|
||||
let plain = input.scaled_plain(size);
|
||||
let mut shapes_found = nan;
|
||||
if !plain.is_empty() {
|
||||
let made: Vec<Outline> = res.paths.iter().filter(|tp| tp.shape.is_some()).map(|tp| Outline::new([&tp.path.to_bezpath()], None)).collect();
|
||||
let found = plain.iter().filter(|p| {
|
||||
let truth = Outline::new([*p], None);
|
||||
made.iter().any(|m| m.distance_to(&truth).1 <= 0.6 && truth.distance_to(m).1 <= 0.6)
|
||||
});
|
||||
shapes_found = 100.0 * found.count() as f64 / plain.len() as f64;
|
||||
}
|
||||
|
||||
Metrics {
|
||||
grouped,
|
||||
gradients,
|
||||
ramp_found,
|
||||
ramp_angle,
|
||||
ramp_off,
|
||||
shapes: shapes_found,
|
||||
centre_off,
|
||||
width_off,
|
||||
stroke_anchors,
|
||||
delta: delta_mean,
|
||||
edge_delta,
|
||||
geo_mean,
|
||||
@@ -275,6 +390,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
anchors: anchors as f64,
|
||||
anchor_ratio: if input.anchors > 0 { anchors as f64 / input.anchors as f64 } else { nan },
|
||||
lines: if length > 0.0 { 100.0 * straight / length } else { nan },
|
||||
upright: if length > 0.0 { 100.0 * upright / length } else { nan },
|
||||
corners,
|
||||
kinks,
|
||||
slivers,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! from ground-truth vector shapes here, never loaded from files.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point};
|
||||
use vectorcraft_trace::Raster;
|
||||
use vectorcraft_trace::{Raster, TracedGradient};
|
||||
|
||||
/// The closed polylines of `path` (one per subpath), flattened within `tol`.
|
||||
pub fn polylines(path: &BezPath, tol: f64) -> Vec<Vec<Point>> {
|
||||
@@ -78,15 +78,25 @@ pub fn coverage(path: &BezPath, w: usize, h: usize) -> Vec<f32> {
|
||||
|
||||
/// Paints `scene` (bottom first) over `bg`, anti-aliased, the way a renderer shows a trace.
|
||||
pub fn render(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize) -> Raster {
|
||||
let scene: Vec<(BezPath, [u8; 3], Option<&TracedGradient>)> = scene.iter().map(|(p, c)| (p.clone(), *c, None)).collect();
|
||||
render_painted(&scene, bg, w, h)
|
||||
}
|
||||
|
||||
/// [`render`], with the paths that have a gradient filled with it.
|
||||
pub fn render_painted(scene: &[(BezPath, [u8; 3], Option<&TracedGradient>)], bg: [u8; 3], w: usize, h: usize) -> Raster {
|
||||
let mut buf = vec![bg.map(f32::from); w * h];
|
||||
let mut cov = vec![0.0f32; w * h];
|
||||
for (path, color) in scene {
|
||||
for (path, color, gradient) in scene {
|
||||
let (r0, r1) = coverage_into(path, w, h, &mut cov);
|
||||
for i in r0 * w..r1 * w {
|
||||
let a = cov[i].min(1.0);
|
||||
if a > 0.0 {
|
||||
let color = match gradient {
|
||||
Some(g) => g.color_at(Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5)).map(|v| v as f32),
|
||||
None => color.map(f32::from),
|
||||
};
|
||||
for ch in 0..3 {
|
||||
buf[i][ch] += (f32::from(color[ch]) - buf[i][ch]) * a;
|
||||
buf[i][ch] += (color[ch] - buf[i][ch]) * a;
|
||||
}
|
||||
cov[i] = 0.0;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show
|
||||
// their pixels as they rasterize, hard-edged.
|
||||
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
|
||||
// Paths made into guides are drawn as the ruler guides are (Preferences › Guides & Grid).
|
||||
let guide_look = LineLook::guides(&app.session.prefs, &t);
|
||||
let key = CacheKey {
|
||||
guide: ([guide_look.color.r(), guide_look.color.g(), guide_look.color.b()], guide_look.dots),
|
||||
doc: st.uid as usize,
|
||||
revision: st.revision,
|
||||
interacting: st.interaction.is_some(),
|
||||
@@ -297,6 +300,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
}),
|
||||
guide: key.guide,
|
||||
mask_view,
|
||||
highlight_substitutions: true,
|
||||
// General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -84,6 +84,7 @@ pub mod spot_colors;
|
||||
pub(crate) mod svg_options;
|
||||
pub mod swatch_conflict;
|
||||
pub mod swatch_options;
|
||||
pub mod symbol_options;
|
||||
pub mod symbolism_options;
|
||||
mod text_export;
|
||||
pub mod text_import;
|
||||
@@ -290,6 +291,7 @@ registry! {
|
||||
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
|
||||
FreehandOptions: [freehand::KIND] => freehand::SPEC,
|
||||
FlareOptions: [flare_options::KIND] => flare_options::SPEC,
|
||||
SymbolOptions: [symbol_options::KIND] => symbol_options::SPEC,
|
||||
SymbolismOptions: [symbolism_options::KIND] => symbolism_options::SPEC,
|
||||
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
|
||||
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Symbol Options: the name of the symbol New Symbol makes from the selection (`symbol.new`), asked
|
||||
//! before the symbol is made, as the Symbols panel's New Symbol button and menu item do.
|
||||
//!
|
||||
//! Fields: `name`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::swatch_options::{grid, label};
|
||||
use super::{DialogSpec, form};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
/// The dialog kind of Symbol Options.
|
||||
pub const KIND: &str = "symbolOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Symbol Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open Symbol Options to name a new symbol made from the selection.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document open")?;
|
||||
if st.selection.is_empty() {
|
||||
return Err("nothing selected".into());
|
||||
}
|
||||
let name = st.doc.unique_symbol_name("New Symbol");
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name })));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
grid(ui, |ui| {
|
||||
label(ui, tl!("Name:"));
|
||||
form::text(ui, d, "name", 190.0);
|
||||
ui.end_row();
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// OK: make the symbol from the selection with the name typed.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let r = app.run("symbol.new", json!({ "name": d.str("name") }));
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -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
|
||||
@@ -807,6 +808,8 @@ lighting Light Světlo
|
||||
File Info Dialog Dialogové okno Informace o souboru
|
||||
Go to Artboard Přejít na pracovní plochu
|
||||
Graphic Style Options… Možnosti grafického stylu…
|
||||
Symbol Options Možnosti symbolu
|
||||
Symbol Options… Možnosti symbolu…
|
||||
Home Domů
|
||||
Import Keyboard Shortcuts… Importovat klávesové zkratky…
|
||||
Justify all lines Do bloku včetně posledního řádku
|
||||
@@ -1424,6 +1427,8 @@ lighting Light Světlo
|
||||
Abutting Sousedící
|
||||
Black and White Černobílé
|
||||
Corners Rohy
|
||||
Auto Grouping Automatické seskupení
|
||||
Ignore Color Ignorovat barvu
|
||||
Ignore White Ignorovat bílou
|
||||
Palette: Paleta:
|
||||
Limited Omezená
|
||||
@@ -3660,6 +3665,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ů?
|
||||
@@ -3734,6 +3740,7 @@ effect Regular Pravidelný
|
||||
@msg No saved selections (Select → Save Selection…) Žádné uložené výběry (Výběr → Uložit výběr…)
|
||||
@msg No saved views (View → New View…) Žádná uložená zobrazení (Zobrazení → Nové zobrazení…)
|
||||
@msg Offset Path: select paths Odsadit cestu: vyberte cesty
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Odsadit cestu: po odsazení z vybraných cest nic nezbude (odsazení dovnitř větší než tvary)
|
||||
@msg Opened with {_1} note(s): {_2} Otevřeno, poznámky ({_1}): {_2}
|
||||
@msg Opened {url} Otevřeno {url}
|
||||
@msg Outline Stroke: no stroked paths selected Převést tah na obrys: nejsou vybrány žádné cesty s tahem
|
||||
@@ -4683,3 +4690,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
|
||||
@@ -1127,6 +1128,8 @@
|
||||
Graphic Style Library Grafikstil-Bibliothek
|
||||
Graphic Style Options Grafikstiloptionen
|
||||
Graphic Style Options… Grafikstiloptionen…
|
||||
Symbol Options Symboloptionen
|
||||
Symbol Options… Symboloptionen…
|
||||
Graphic Styles Grafikstile
|
||||
Graphic Styles Options Optionen für Grafikstile
|
||||
Graphics Grafik
|
||||
@@ -1226,6 +1229,8 @@
|
||||
Identify Objects By Objekte identifizieren nach
|
||||
Ignore Ignorieren
|
||||
Ignore Artboards (all the art as one page) Zeichenflächen ignorieren (gesamte Grafik als eine Seite)
|
||||
Auto Grouping Automatisch gruppieren
|
||||
Ignore Color Farbe ignorieren
|
||||
Ignore White Weiß ignorieren
|
||||
Palette: Palette:
|
||||
Limited Begrenzt
|
||||
@@ -3135,6 +3140,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?
|
||||
@@ -3335,6 +3341,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg No saved selections (Select → Save Selection…) Keine gespeicherten Auswahlen (Auswahl → Auswahl speichern…)
|
||||
@msg No saved views (View → New View…) Keine gespeicherten Ansichten (Ansicht → Neue Ansicht…)
|
||||
@msg Offset Path: select paths Pfad verschieben: Wählen Sie Pfade aus
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Pfad verschieben: Der Versatz lässt von den ausgewählten Pfaden nichts übrig (ein Versatz nach innen, der größer ist als die Formen)
|
||||
@msg Opened with {_1} note(s): {_2} Mit {_1} Hinweis(en) geöffnet: {_2}
|
||||
@msg Opened {url} {url} geöffnet
|
||||
@msg Outline Stroke: no stroked paths selected Konturlinie: keine Pfade mit Kontur ausgewählt
|
||||
@@ -4206,3 +4213,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
|
||||
@@ -1144,6 +1145,8 @@
|
||||
Graphic Style Library Biblioteca de estilos gráficos
|
||||
Graphic Style Options Opciones de estilo gráfico
|
||||
Graphic Style Options… Opciones de estilo gráfico…
|
||||
Symbol Options Opciones de símbolo
|
||||
Symbol Options… Opciones de símbolo…
|
||||
Graphic Styles Estilos gráficos
|
||||
Graphic Styles Options Opciones de estilos gráficos
|
||||
Graphics Gráficos
|
||||
@@ -1244,6 +1247,8 @@
|
||||
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)
|
||||
Auto Grouping Agrupación automática
|
||||
Ignore Color Ignorar color
|
||||
Ignore White Ignorar blanco
|
||||
Palette: Paleta:
|
||||
Limited Limitada
|
||||
@@ -3236,6 +3241,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?
|
||||
@@ -3435,6 +3441,7 @@ axis Both Ambos
|
||||
@msg No saved selections (Select → Save Selection…) No hay selecciones guardadas (Selección → Guardar selección…)
|
||||
@msg No saved views (View → New View…) No hay vistas guardadas (Ver → Nueva vista…)
|
||||
@msg Offset Path: select paths Desplazar trazado: selecciona trazados
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Desplazar trazado: el desplazamiento no deja nada de los trazados seleccionados (un desplazamiento hacia dentro mayor que las formas)
|
||||
@msg Opened with {_1} note(s): {_2} Se abrió con {_1} nota(s): {_2}
|
||||
@msg Opened {url} Se abrió {url}
|
||||
@msg Outline Stroke: no stroked paths selected Contornear trazo: no hay trazados con trazo seleccionados
|
||||
@@ -4623,3 +4630,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.
|
@@ -248,6 +248,7 @@
|
||||
Author Title: Fonction de l’auteur :
|
||||
Author: Auteur :
|
||||
Auto Auto
|
||||
Auto Color Couleur automatique
|
||||
Automatic Automatique
|
||||
Auto Size New Area Type Taille automatique du nouveau texte captif
|
||||
Auto Spell Check Vérification orthographique automatique
|
||||
@@ -1143,6 +1144,8 @@
|
||||
Graphic Style Library Bibliothèque de styles graphiques
|
||||
Graphic Style Options Options de style graphique
|
||||
Graphic Style Options… Options de style graphique…
|
||||
Symbol Options Options de symbole
|
||||
Symbol Options… Options de symbole…
|
||||
Graphic Styles Styles graphiques
|
||||
Graphic Styles Options Options des styles graphiques
|
||||
Graphics Graphiques
|
||||
@@ -1243,6 +1246,8 @@
|
||||
Identify Objects By Identifier les objets par
|
||||
Ignore Ignorer
|
||||
Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page)
|
||||
Auto Grouping Groupement automatique
|
||||
Ignore Color Ignorer la couleur
|
||||
Ignore White Ignorer le blanc
|
||||
Palette: Palette :
|
||||
Limited Limitée
|
||||
@@ -3233,6 +3238,7 @@
|
||||
{warning}: click to correct to the closest color ({hex}) {warning} : cliquez pour corriger avec la couleur la plus proche ({hex})
|
||||
“{file}” is protected by a password. « {file} » est protégé par un mot de passe.
|
||||
● Recording ● Enregistrement
|
||||
imageTrace Outline Contour
|
||||
@plural {n} item|{n} items {n} élément|{n} éléments
|
||||
@plural Color Table ({n} color)|Color Table ({n} colors) Table des couleurs ({n} couleur)|Table des couleurs ({n} couleurs)
|
||||
@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Supprimer {n} style graphique ?|Supprimer ces {n} styles graphiques ?
|
||||
@@ -3432,6 +3438,7 @@ axis Both Les deux
|
||||
@msg No saved selections (Select → Save Selection…) Aucune sélection enregistrée (Sélection → Enregistrer la sélection…)
|
||||
@msg No saved views (View → New View…) Aucune vue enregistrée (Affichage → Nouvelle vue…)
|
||||
@msg Offset Path: select paths Décalage : sélectionnez des tracés
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Décalage : le décalage ne laisse rien des tracés sélectionnés (un décalage vers l’intérieur plus grand que les formes)
|
||||
@msg Opened with {_1} note(s): {_2} Ouvert avec {_1} remarque(s) : {_2}
|
||||
@msg Opened {url} {url} ouvert
|
||||
@msg Outline Stroke: no stroked paths selected Vectoriser le contour : aucun tracé avec contour sélectionné
|
||||
@@ -4682,3 +4689,5 @@ file-filter VectorCraft Template Modèle VectorCraft
|
||||
gradient-kind Radial Radial
|
||||
mark-style Japanese Japonais
|
||||
theme Appearance Apparence
|
||||
Load EPS into Clipboard Charger un EPS dans le presse-papiers
|
||||
@msg an image of {w} × {h} pixels is too large to draw une image de {w} × {h} pixels est trop grande pour être dessinée
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
@@ -1469,6 +1470,8 @@
|
||||
Graphic Style Library Grafikaistílus-könyvtár
|
||||
Graphic Style Options Grafikai stílus beállításai
|
||||
Graphic Style Options… Grafikai stílus beállításai…
|
||||
Symbol Options Szimbólum beállításai
|
||||
Symbol Options… Szimbólum beállításai…
|
||||
Graphic Styles Grafikai stílusok
|
||||
Graphic Styles (4) Grafikai stílusok (4)
|
||||
Graphic Styles Options Grafikai stílusok beállításai
|
||||
@@ -1608,6 +1611,8 @@
|
||||
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)
|
||||
Auto Grouping Automatikus csoportosítás
|
||||
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 +3940,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}
|
||||
@@ -4333,6 +4339,7 @@ theme Appearance Kinézet
|
||||
@msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id}
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető
|
||||
@msg Offset Path: select paths Útvonal eltolása: jelölj ki útvonalakat
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Útvonal eltolása: az eltolás után semmi sem marad a kijelölt útvonalakból (a befelé eltolás nagyobb az alakzatoknál)
|
||||
@msg onto must name a floating panel az onto egy lebegő panelt kell megnevezzen
|
||||
@msg onto must name a panel floating in another group az onto egy másik csoportban lebegő panelt kell megnevezzen
|
||||
@msg Opened with {_1} note(s): {_2} Megnyitva; megjegyzések: {_1}: {_2}
|
||||
@@ -4649,3 +4656,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
|
||||
@@ -1143,6 +1144,8 @@
|
||||
Graphic Style Library Libreria stili grafici
|
||||
Graphic Style Options Opzioni stile grafico
|
||||
Graphic Style Options… Opzioni stile grafico…
|
||||
Symbol Options Opzioni simbolo
|
||||
Symbol Options… Opzioni simbolo…
|
||||
Graphic Styles Stili grafici
|
||||
Graphic Styles Options Opzioni stili grafici
|
||||
Graphics Grafica
|
||||
@@ -1243,6 +1246,8 @@
|
||||
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)
|
||||
Auto Grouping Raggruppamento automatico
|
||||
Ignore Color Ignora colore
|
||||
Ignore White Ignora bianco
|
||||
Palette: Palette:
|
||||
Limited Limitata
|
||||
@@ -3236,6 +3241,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?
|
||||
@@ -3435,6 +3441,7 @@ axis Both Entrambi
|
||||
@msg No saved selections (Select → Save Selection…) Nessuna selezione salvata (Selezione → Salva selezione…)
|
||||
@msg No saved views (View → New View…) Nessuna vista salvata (Visualizza → Nuova vista…)
|
||||
@msg Offset Path: select paths Sposta tracciato: seleziona tracciati
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Sposta tracciato: lo spostamento non lascia nulla dei tracciati selezionati (uno spostamento verso l’interno maggiore delle forme)
|
||||
@msg Opened with {_1} note(s): {_2} Aperto con {_1} nota/e: {_2}
|
||||
@msg Opened {url} Aperto {url}
|
||||
@msg Outline Stroke: no stroked paths selected Contorna traccia: nessun tracciato con traccia selezionato
|
||||
@@ -4251,3 +4258,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 分離モードを終了
|
||||
@@ -1421,6 +1422,8 @@
|
||||
Graphic Style Library グラフィックスタイルライブラリ
|
||||
Graphic Style Options グラフィックスタイルオプション
|
||||
Graphic Style Options… グラフィックスタイルオプション…
|
||||
Symbol Options シンボルオプション
|
||||
Symbol Options… シンボルオプション…
|
||||
Graphic Styles Options グラフィックスタイルオプション
|
||||
Graphics Processor グラフィックプロセッサー
|
||||
Graphics: {adapter} グラフィックス: {adapter}
|
||||
@@ -1481,6 +1484,8 @@
|
||||
Identify Objects By オブジェクトの識別方法
|
||||
Ignore 無視
|
||||
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
|
||||
Auto Grouping 自動グループ化
|
||||
Ignore Color カラーを無視
|
||||
Ignore White 白を無視
|
||||
Palette: パレット:
|
||||
Limited 限定
|
||||
@@ -3225,6 +3230,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} レイヤー
|
||||
@@ -3592,6 +3598,7 @@ theme Appearance 外観
|
||||
@msg Divide Objects Below: select a path 背面のオブジェクトで型抜き: パスを選択してください
|
||||
@msg Divide Objects Below: select exactly one path 背面のオブジェクトで型抜き: パスを 1 つだけ選択してください
|
||||
@msg Offset Path: select paths パスのオフセット: パスを選択してください
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) パスのオフセット: オフセットすると選択したパスが何も残りません (内側へのオフセットがシェイプより大きい)
|
||||
@msg Outline Stroke: no stroked paths selected パスのアウトライン: 線のあるパスが選択されていません
|
||||
@msg Split Into Grid: select paths large enough for the grid グリッドに分割: グリッドが収まる大きさのパスを選択してください
|
||||
@msg empty cutter 型抜きに使うパスが空です
|
||||
@@ -4245,3 +4252,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 격리 모드 종료
|
||||
@@ -1416,6 +1417,8 @@
|
||||
Graphic Style Library 그래픽 스타일 라이브러리
|
||||
Graphic Style Options 그래픽 스타일 옵션
|
||||
Graphic Style Options… 그래픽 스타일 옵션…
|
||||
Symbol Options 심볼 옵션
|
||||
Symbol Options… 심볼 옵션…
|
||||
Graphic Styles Options 그래픽 스타일 옵션
|
||||
Graphics Processor 그래픽 프로세서
|
||||
Graphics: {adapter} 그래픽: {adapter}
|
||||
@@ -1476,6 +1479,8 @@
|
||||
Identify Objects By 개체 식별 기준
|
||||
Ignore 무시
|
||||
Ignore Artboards (all the art as one page) 아트보드 무시 (모든 아트웍을 한 페이지로)
|
||||
Auto Grouping 자동 그룹화
|
||||
Ignore Color 색상 무시
|
||||
Ignore White 흰색 무시
|
||||
Palette: 팔레트:
|
||||
Limited 제한
|
||||
@@ -3218,6 +3223,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}개
|
||||
@@ -3570,6 +3576,7 @@ theme Appearance 외관
|
||||
@msg Divide Objects Below: select a path 아래 개체 분할: 패스를 선택하세요
|
||||
@msg Divide Objects Below: select exactly one path 아래 개체 분할: 패스를 하나만 선택하세요
|
||||
@msg Offset Path: select paths 패스 오프셋: 패스를 선택하세요
|
||||
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) 패스 오프셋: 오프셋을 적용하면 선택한 패스가 남지 않습니다(안쪽 오프셋이 모양보다 큼)
|
||||
@msg Outline Stroke: no stroked paths selected 선 윤곽선화: 선이 있는 패스가 선택되지 않았습니다
|
||||
@msg Split Into Grid: select paths large enough for the grid 격자로 분할: 격자에 충분한 크기의 패스를 선택하세요
|
||||
@msg empty cutter 빈 커터입니다
|
||||
@@ -4215,3 +4222,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; 15] = [
|
||||
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`).
|
||||
@@ -156,6 +168,10 @@ pub static LANGUAGES: [LangInfo; 15] = [
|
||||
// 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
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user