mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 03:36:51 -04:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c91c36e7e | ||
|
|
8ab574b7de | ||
|
|
e0ba6e77d1 | ||
|
|
9c289db91d | ||
|
|
b1a5b79d67 | ||
|
|
156fbe9912 | ||
|
|
1f4daf519d | ||
|
|
638d55983f | ||
|
|
c6d9822f26 | ||
|
|
fe83800ed5 | ||
|
|
567f5edaf3 | ||
|
|
0a6a047fd9 | ||
|
|
534f00dfd3 | ||
|
|
c5b2495291 | ||
|
|
7dd72dee58 | ||
|
|
d6b63fbc87 | ||
|
|
fb905ae911 | ||
|
|
841ea1716d | ||
|
|
7975b07b22 | ||
|
|
a1a27636e1 | ||
|
|
33a5f2a801 | ||
|
|
fdf6ba9c9d | ||
|
|
b3ac169f00 | ||
|
|
dfb0ce36bd |
No files matched your search
+7
-1
@@ -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-14 · **Change:** minor (merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; 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
|
||||
@@ -125,8 +125,12 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
|
||||
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
|
||||
| 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number movLine truncated
|
||||
| 2026-10-13 | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565). SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596). Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568). The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598). The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601). SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567). PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585). The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584). GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570). Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590). The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560). Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616). Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571). Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel kLine truncated
|
||||
| 2026-10-13 | Control sockets close after bounded idle, partial-line and reply-write waits, returning their connection slot (#1726); existing command processing timeout remains separate. |
|
||||
@@ -162,6 +166,8 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
|
||||
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
|
||||
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
|
||||
|
||||
@@ -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 (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}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), 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)
|
||||
),
|
||||
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
trace_set_view
|
||||
),
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version, null if it has none}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -657,7 +657,10 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
|
||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
|
||||
// (A preset without an enhanced version is traced as it is.)
|
||||
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
||||
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
|
||||
@@ -738,8 +741,9 @@ fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Pain
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had:
|
||||
/// abutting paths painted in another order would show a seam where they meet. 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];
|
||||
@@ -761,8 +765,14 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
|
||||
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()) };
|
||||
let mut within: Vec<(usize, Arc<Node>)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect();
|
||||
let made = if within.is_empty() {
|
||||
node
|
||||
} else {
|
||||
within.push((i, Arc::new(node)));
|
||||
within.sort_by_key(|m| m.0);
|
||||
Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect())
|
||||
};
|
||||
if let Some(slot) = built.get_mut(i) {
|
||||
*slot = Some(Arc::new(made));
|
||||
}
|
||||
@@ -778,12 +788,11 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let mut params = trace_params(p)?;
|
||||
let view = view_param(C, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's, else "Custom".
|
||||
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
|
||||
// else "Custom".
|
||||
let named = str_param(p, "preset").unwrap_or("Default");
|
||||
let preset_label = match tr::preset(named) {
|
||||
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(¶ms_json) => named.to_string(),
|
||||
_ => "Custom".to_string(),
|
||||
};
|
||||
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json);
|
||||
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
|
||||
let roots = root_ids(s, p)?;
|
||||
// The image (inside a trace group, or selected directly).
|
||||
let (target, img) = {
|
||||
@@ -848,10 +857,10 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
// 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.
|
||||
// Traced lines: round corners, and ends round or square as the tracer found them.
|
||||
Some(width) => Appearance {
|
||||
items: vec![AppearanceItem::Stroke(StrokeLayer {
|
||||
cap: LineCap::Round,
|
||||
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
|
||||
join: LineJoin::Round,
|
||||
..StrokeLayer::new(paint, width * unit)
|
||||
})],
|
||||
|
||||
@@ -737,6 +737,18 @@ fn image_trace_auto_grouping_groups_what_is_inside() {
|
||||
(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));
|
||||
// In black and white too, where the white inside the ring is a path traced before the ring:
|
||||
// it stays under the ring in their group (over it, the ring's inner edge would show a seam).
|
||||
let bw = |grouping: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"autoGrouping": grouping}})).unwrap();
|
||||
let groups = traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).count();
|
||||
(s, groups)
|
||||
};
|
||||
let (grouped, flat) = (bw(true), bw(false));
|
||||
assert!(grouped.1 > 0 && flat.1 == 0);
|
||||
assert_eq!(shot(&grouped.0), shot(&flat.0));
|
||||
// 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);
|
||||
@@ -773,6 +785,29 @@ fn image_trace_gradients_fills_with_a_gradient() {
|
||||
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
|
||||
/// A preset's enhanced version: the preset with Auto Grouping and Shapes, under the preset's name.
|
||||
#[test]
|
||||
fn image_trace_enhanced_presets() {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "enhanced": true})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let stored = n.extras().trace.clone().unwrap();
|
||||
assert_eq!(stored["preset"], "6 Colors");
|
||||
assert_eq!((stored["params"]["shapes"].as_bool(), stored["params"]["autoGrouping"].as_bool()), (Some(true), Some(true)));
|
||||
// The disc of the test image is a live ellipse, in a group with nothing (it has nothing inside).
|
||||
let shapes: Vec<_> = traced_paths(&n).into_iter().filter(|c| matches!(&c.kind, NodeKind::Path { live: Some(_), .. })).collect();
|
||||
assert_eq!(shapes.len(), 1);
|
||||
// Listed with each preset; the legacy preset is as it was.
|
||||
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let six = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "6 Colors").unwrap().clone();
|
||||
assert_eq!((six["params"]["shapes"].as_bool(), six["enhanced"]["shapes"].as_bool()), (Some(false), Some(true)));
|
||||
assert_eq!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "High Fidelity Photo").unwrap()["enhanced"]["shapes"], false);
|
||||
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such", "enhanced": true})).is_err());
|
||||
// Flat Logo, a preset of our own, has no enhanced version.
|
||||
assert!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Flat Logo").unwrap()["enhanced"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_release_and_expand() {
|
||||
let mut s = session();
|
||||
@@ -874,7 +909,8 @@ fn image_trace_create_strokes_makes_stroked_centre_lines() {
|
||||
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
|
||||
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
|
||||
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
|
||||
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
|
||||
// (The line is cut square at its ends, and so is its stroke.)
|
||||
assert_eq!((st.cap, st.join), (LineCap::Square, LineJoin::Round));
|
||||
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
let sp = &line.path_data().unwrap().subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
|
||||
+15
-2
@@ -869,10 +869,10 @@ impl Renderer {
|
||||
// A symbol's art can change while the instance doesn't: no cached shadows.
|
||||
let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0);
|
||||
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) };
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true));
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
_ => self.draw_node(ctx, f, &art, true),
|
||||
_ => self.draw_fx_art(ctx, f, a, &art),
|
||||
}
|
||||
self.made_again = outer;
|
||||
}
|
||||
@@ -939,6 +939,19 @@ impl Renderer {
|
||||
art
|
||||
}
|
||||
|
||||
/// Draw `art`, the art [`Self::fx_art`] made for `a`. Type art that `fx_arts` keeps for `a` is
|
||||
/// drawn as `fx_owner`, so [`Self::draw_text`] takes its glyph geometry from `texts`.
|
||||
fn draw_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, art: &Arc<Node>) {
|
||||
let kept =
|
||||
matches!(art.kind, NodeKind::Text(_)) && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, art));
|
||||
if !kept {
|
||||
return self.draw_node(ctx, f, art, true);
|
||||
}
|
||||
let outer = self.fx_owner.replace(art.clone());
|
||||
self.draw_node(ctx, f, art, true);
|
||||
self.fx_owner = outer;
|
||||
}
|
||||
|
||||
/// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on
|
||||
/// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the
|
||||
/// decoded images under a key naming the adjustment.
|
||||
|
||||
@@ -1602,7 +1602,14 @@ impl Renderer {
|
||||
|
||||
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
|
||||
let t = &*f.doc.inline_resolved(t);
|
||||
let g = text_geom_snapped(t, f.text_snap(t));
|
||||
// Type drawn as `fx_owner` (the node `draw_arc` is drawing, or type art that `fx_arts`
|
||||
// keeps, see `draw_fx_art`) takes its glyph geometry from `texts`, as on the fast path,
|
||||
// except in frames that snap type to pixels.
|
||||
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned();
|
||||
let g = match (f.text_snap(t), owner) {
|
||||
(None, Some(a)) => self.text_geom_of(&a, t),
|
||||
(snap, _) => Arc::new(text_geom_snapped(t, snap)),
|
||||
};
|
||||
self.draw_text_geom(ctx, f, n, t, &g);
|
||||
}
|
||||
|
||||
@@ -1670,7 +1677,12 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
|
||||
/// Text `t` that is not the node's own, such as the copy whose inline graphics
|
||||
/// `Document::inline_resolved` sized again, is laid out without the cache.
|
||||
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
|
||||
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
|
||||
return Arc::new(text_geom(t));
|
||||
}
|
||||
let fonts = vectorcraft_text::FontDb::global().generation();
|
||||
if fonts != self.text_fonts {
|
||||
self.texts.clear();
|
||||
|
||||
@@ -83,6 +83,37 @@ fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
/// available: the glyph geometry kept for it is laid out again.
|
||||
#[test]
|
||||
fn translucent_blended_and_shadowed_type_redraws_when_its_font_becomes_available() {
|
||||
const FAMILY: &str = "Blended Serif4";
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..3 {
|
||||
let style = CharStyle { size: 36.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 40.0 + 45.0 * i as f64), "Hamburg", style))));
|
||||
match i {
|
||||
0 => n.opacity = 0.8,
|
||||
1 => n.blend = vectorcraft_color::BlendMode::Multiply,
|
||||
_ => n.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
params: serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}),
|
||||
visible: true,
|
||||
}),
|
||||
}
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert_eq!(r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, before, "drawn again from the kept glyph geometry");
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert_ne!(before, after, "drawn in the font that arrived");
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type with a geometry effect in a symbol's art redraws in a font that becomes available: the art
|
||||
/// kept for the instance is placed again when the fonts change.
|
||||
#[test]
|
||||
|
||||
@@ -182,6 +182,122 @@ fn swept_type_entries_give_back_their_bytes() {
|
||||
assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0));
|
||||
}
|
||||
|
||||
/// [`text_doc`] of three type objects reading `word`: one at 80% opacity, one in Multiply and one
|
||||
/// with a drop shadow.
|
||||
fn faded_text_doc(word: &str) -> Document {
|
||||
let mut d = text_doc(3, word);
|
||||
let ids: Vec<NodeId> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||||
d.node_mut(ids[0]).unwrap().opacity = 0.8;
|
||||
d.node_mut(ids[1]).unwrap().blend = vectorcraft_color::BlendMode::Multiply;
|
||||
d.node_mut(ids[2]).unwrap().appearance.effects.push(drop_shadow());
|
||||
d
|
||||
}
|
||||
|
||||
/// The glyph geometry `r` keeps for each type object of `d`: under its node, or under the art
|
||||
/// drawn for it when it has effects.
|
||||
fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec<Option<Arc<TextGeom>>> {
|
||||
let stamp = r.stamp;
|
||||
text_keys(d)
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
let k = r.fx_arts.get(&k, stamp).map_or(k, |e| Arc::as_ptr(&e.1) as usize);
|
||||
r.texts.get(&k, stamp).map(|(_, g)| g.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the
|
||||
/// renderer lays all type out again when the fonts change.
|
||||
fn fonts_unchanged<T>(mut f: impl FnMut() -> T) -> T {
|
||||
loop {
|
||||
let fonts = vectorcraft_text::FontDb::global().generation();
|
||||
let t = f();
|
||||
if vectorcraft_text::FontDb::global().generation() == fonts {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow keeps its glyph geometry between
|
||||
/// frames, as opaque type does: the second frame draws the geometry the first laid out. Frames
|
||||
/// that snap type to pixels keep none, and neither does type art that the effect-art cache has no
|
||||
/// room for.
|
||||
#[test]
|
||||
fn translucent_blended_and_shadowed_type_keeps_its_glyph_geometry_between_frames() {
|
||||
let d = faded_text_doc("Kept");
|
||||
let opts = RenderOptions::default();
|
||||
let (first, kept, entries, second, again) = fonts_unchanged(|| {
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels;
|
||||
let (kept, entries) = (kept_glyphs(&mut r, &d), r.texts.len());
|
||||
let second = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels;
|
||||
(first, kept, entries, second, kept_glyphs(&mut r, &d))
|
||||
});
|
||||
assert!(kept.iter().all(Option::is_some) && entries == 3, "{entries} entries");
|
||||
assert!(second == first);
|
||||
let same = kept.iter().zip(&again).all(|pair| matches!(pair, (Some(a), Some(b)) if Arc::ptr_eq(a, b)));
|
||||
assert!(same, "the second frame draws the geometry the first laid out");
|
||||
// Type anti-aliasing snaps the glyphs to pixels.
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions { anti_alias: AntiAlias::Type, ..Default::default() });
|
||||
assert!(r.texts.is_empty());
|
||||
// With no room for effect art, the shadowed type's art is made in every frame.
|
||||
let frames = fonts_unchanged(|| {
|
||||
let mut r = Renderer::new();
|
||||
r.fx_arts = FrameCache::new(0, 0);
|
||||
(0..2).map(|_| (r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels == first, r.texts.len())).collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(frames, [(true, 2), (true, 2)]);
|
||||
}
|
||||
|
||||
/// Editing the text of type at 80% opacity, in Multiply or with a drop shadow lays it out again:
|
||||
/// the next frame draws what a new renderer draws.
|
||||
#[test]
|
||||
fn edited_translucent_blended_and_shadowed_type_draws_as_new() {
|
||||
for i in 0..3 {
|
||||
let mut d = faded_text_doc("Kept");
|
||||
let id = d.layers[0].children().unwrap()[i].id;
|
||||
let opts = RenderOptions::default();
|
||||
draws_as_new(&mut d, &opts, &opts, |d| {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].text = "Edit".into();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Type showing an inline graphic, at 80% opacity, in Multiply, with a drop shadow and opaque:
|
||||
/// when the symbol's art changes size and the document's inline graphics are not resolved again,
|
||||
/// the next frame lays the type out with the new size, as a new renderer does.
|
||||
#[test]
|
||||
fn type_with_an_inline_graphic_draws_its_symbols_new_size() {
|
||||
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
|
||||
for i in 0..4 {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let style = CharStyle { size: 24.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone());
|
||||
t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone()));
|
||||
t.runs.push(TextRun::new("B", style));
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
match i {
|
||||
0 => n.opacity = 0.8,
|
||||
1 => n.blend = vectorcraft_color::BlendMode::Multiply,
|
||||
2 => n.appearance.effects.push(drop_shadow()),
|
||||
_ => {}
|
||||
}
|
||||
let mut d = with_art(d, vec![n]);
|
||||
d.resolve_inline_art();
|
||||
// `S` becomes a wide red bar: the inline graphic grows, and the type after it moves.
|
||||
edit_draws_as_new(d, |d| {
|
||||
let bar = vectorcraft_geom::shapes::rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0));
|
||||
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol's art inside the 20 × 20 square around the origin: a group of a square filled blue
|
||||
/// with a 2 pt black stroke and a black line.
|
||||
fn symbol_art(d: &mut Document) -> Node {
|
||||
|
||||
@@ -320,6 +320,10 @@ type Catalog = HashMap<String, CatalogFamily>;
|
||||
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
|
||||
type PostScriptNames = HashMap<String, (String, String)>;
|
||||
|
||||
/// Maps the family and style [`FontDb::canonical`] was asked for to its answer, for names that
|
||||
/// are neither a loaded nor an installed family.
|
||||
type CanonicalNames = HashMap<(String, String), (String, String)>;
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
@@ -332,6 +336,9 @@ pub struct FontDb {
|
||||
font_paths: FontPaths,
|
||||
/// The installed fonts' other names (localized, legacy, full, PostScript) by normalized name.
|
||||
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
||||
/// Keeps [`FontDb::canonical`]'s answers for names other than the loaded and installed
|
||||
/// families until fonts are added or rescanned.
|
||||
canonical_cache: Mutex<CanonicalNames>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -455,6 +462,9 @@ const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// How many answers [`FontDb::canonical`] keeps at most. When it has kept that many, it clears
|
||||
/// them before it keeps the next one.
|
||||
const CANONICAL_CACHE_MAX: usize = 4_096;
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
@@ -717,6 +727,8 @@ struct Alias {
|
||||
impl Alias {
|
||||
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
|
||||
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
|
||||
#[cfg(test)]
|
||||
ALIAS_LISTS.with(|c| c.set(c.get() + 1));
|
||||
let mut v: Vec<(String, Alias)> =
|
||||
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
|
||||
v.extend(
|
||||
@@ -734,6 +746,18 @@ impl Alias {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: counts the faces whose aliases ([`Alias::of`]) were listed on this thread.
|
||||
static ALIAS_LISTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: returns how many faces' aliases were listed on this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn alias_lists() -> u64 {
|
||||
ALIAS_LISTS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// How a requested family and style resolved ([`FontDb::resolve`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontMatch {
|
||||
@@ -1467,6 +1491,7 @@ impl FontDb {
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_paths,
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
canonical_cache: Mutex::new(CanonicalNames::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1509,9 +1534,11 @@ impl FontDb {
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Fonts were added or the catalog changed: cached family lists are stale.
|
||||
/// Fonts were added or the catalog changed: cached family lists and [`FontDb::canonical`]'s
|
||||
/// answers are stale.
|
||||
fn changed(&self) {
|
||||
*self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.generation.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -1764,6 +1791,24 @@ impl FontDb {
|
||||
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
|
||||
return (family.to_string(), style.to_string());
|
||||
}
|
||||
// The lock is held until the answer is kept, and [`FontDb::changed`] waits for it: an
|
||||
// answer found from the fonts before a change is cleared by that change.
|
||||
let mut cache = self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = (family.to_string(), style.to_string());
|
||||
if let Some(answer) = cache.get(&key) {
|
||||
return answer.clone();
|
||||
}
|
||||
let answer = self.canonical_alias(family, style);
|
||||
if cache.len() >= CANONICAL_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, answer.clone());
|
||||
answer
|
||||
}
|
||||
|
||||
/// Finds [`FontDb::canonical`]'s answer for a name that is neither a loaded nor an installed
|
||||
/// family among the loaded faces' and the installed fonts' other names.
|
||||
fn canonical_alias(&self, family: &str, style: &str) -> (String, String) {
|
||||
let key = norm(family);
|
||||
let mut found: Vec<Alias> =
|
||||
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
|
||||
@@ -2230,6 +2275,19 @@ mod alias_tests {
|
||||
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
|
||||
assert!(db.has_family("SourceSans3-Regular"));
|
||||
}
|
||||
|
||||
/// The database keeps at most [`CANONICAL_CACHE_MAX`] answers, and names asked for past that
|
||||
/// many still get the same answers.
|
||||
#[test]
|
||||
fn the_answers_kept_for_names_are_capped() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for i in 0..=CANONICAL_CACHE_MAX {
|
||||
let name = format!("No Such Family {i}");
|
||||
assert_eq!(db.canonical(&name, "Regular"), (name.clone(), "Regular".into()));
|
||||
}
|
||||
assert!(db.canonical_cache.lock().unwrap().len() <= CANONICAL_CACHE_MAX);
|
||||
assert_eq!(db.canonical("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -663,3 +663,91 @@ fn a_full_name_does_not_override_an_installed_family() {
|
||||
let (face, matched) = db.resolve("Alias Script Italic", "Regular").unwrap();
|
||||
assert_eq!((face.family.as_str(), face.style.as_str(), matched), ("Alias Script Italic", "Regular", FontMatch::Exact));
|
||||
}
|
||||
|
||||
/// When type is set in a font that isn't installed, layout searches the loaded faces' names for the
|
||||
/// font's name once until fonts are added.
|
||||
#[test]
|
||||
fn a_missing_fonts_name_is_looked_up_once_until_fonts_are_added() {
|
||||
use crate::fontdb::alias_lists;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
// One lookup lists the names of every loaded face.
|
||||
let before = alias_lists();
|
||||
assert_eq!(db.canonical("Gone Sans", "Bold"), ("Gone Sans".to_string(), "Bold".to_string()));
|
||||
let one_lookup = alias_lists() - before;
|
||||
let style = vectorcraft_doc::CharStyle { font_family: "Gone Sans".into(), ..Default::default() };
|
||||
let t = TextObject::point(kurbo::Point::ZERO, &vec!["Set in a font that is not installed."; 20].join("\n"), style);
|
||||
let before = alias_lists();
|
||||
for _ in 0..2 {
|
||||
assert_eq!(layout(&db, &t).lines.len(), 20);
|
||||
}
|
||||
assert_eq!(alias_lists() - before, one_lookup, "two layouts of 20 paragraphs list the faces' names once");
|
||||
// A font added since has the name as its full name.
|
||||
assert_eq!(db.add_font(named(&[(3, EN, 1, "Found Sans"), (3, EN, 2, "Bold"), (3, EN, 4, "Gone Sans"), (3, EN, 6, "FoundSans-Bold")])), 1);
|
||||
let l = layout(&db, &t);
|
||||
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| db.face_by_id(g.font_id).is_some_and(|f| f.family == "Found Sans")));
|
||||
}
|
||||
|
||||
/// Until an installed font has a name, `canonical` returns the name and the style asked for each
|
||||
/// time; once a rescan finds the font, the name gives the font's family.
|
||||
#[test]
|
||||
fn a_name_asked_for_before_its_font_is_installed_finds_it_after_a_rescan() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-asked-before", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
let answer = |family: &str, style: &str| (family.to_string(), style.to_string());
|
||||
for _ in 0..2 {
|
||||
for style in ["Regular", "Bold"] {
|
||||
assert_eq!(db.canonical("Later Three W3", style), answer("Later Three W3", style));
|
||||
assert_eq!(db.canonical("後の三", style), answer("後の三", style));
|
||||
}
|
||||
}
|
||||
// The name table is laid out like Hiragino Sans W3's, with a legacy family paired with its
|
||||
// legacy style.
|
||||
let three = named(&[
|
||||
(1, 0, 1, "Later Three"),
|
||||
(1, 0, 2, "W3"),
|
||||
(3, EN, 1, "Later Three W3"),
|
||||
(3, EN, 2, "Regular"),
|
||||
(3, EN, 6, "LThree-W3"),
|
||||
(3, EN, 16, "Later Three"),
|
||||
(3, EN, 17, "W3"),
|
||||
(3, JA, 16, "後の三"),
|
||||
(3, JA, 17, "W3"),
|
||||
]);
|
||||
std::fs::write(dir.join("LaterThree.ttf"), three).unwrap();
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(db.canonical("Later Three W3", "Regular"), answer("Later Three", "W3"));
|
||||
assert_eq!(db.canonical("Later Three W3", "Bold"), answer("Later Three", "Bold"));
|
||||
assert_eq!(db.canonical("後の三", "Bold"), answer("Later Three", "Bold"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Times the layout of a story of 1,000 paragraphs set in a font that is not installed, with 0, 100
|
||||
/// and 400 fonts added to the bundled ones. Run with
|
||||
/// `cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
|
||||
fn missing_font_story_layout_timing() {
|
||||
let text = vec!["Type set in a font that is not installed on this computer."; 1000].join("\n");
|
||||
let style = vectorcraft_doc::CharStyle { font_family: "No Such Font".into(), ..Default::default() };
|
||||
let t = TextObject::point(kurbo::Point::ZERO, &text, style);
|
||||
for extra in [0, 100, 400] {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for i in 0..extra {
|
||||
let (family, full, postscript) = (format!("Loaded Sans {i}"), format!("Loaded Sans {i} Regular"), format!("LoadedSans{i}-Regular"));
|
||||
assert_eq!(db.add_font(named(&[(3, EN, 1, &family), (3, EN, 2, "Regular"), (3, EN, 4, &full), (3, EN, 6, &postscript)])), 1);
|
||||
}
|
||||
let mut ms = vec![];
|
||||
for _ in 0..7 {
|
||||
let start = std::time::Instant::now();
|
||||
let l = layout(&db, &t);
|
||||
ms.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
assert_eq!(l.lines.len(), 1000);
|
||||
}
|
||||
ms.sort_by(f64::total_cmp);
|
||||
println!("{extra} fonts added, {} families loaded: median {:.1} ms; samples {ms:.1?}", db.family_list().len(), ms[3]);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub(crate) struct Line {
|
||||
/// Stroke width in pixels.
|
||||
pub width: f64,
|
||||
pub pixels: usize,
|
||||
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
|
||||
pub square: bool,
|
||||
}
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
@@ -374,22 +376,48 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
|
||||
// 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.
|
||||
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
|
||||
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
|
||||
// of the line's middle as along it, where a round end stops short by a seventh of the width.
|
||||
let (mut short_by, mut told) = (0.0, 0);
|
||||
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;
|
||||
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
|
||||
// The last points of a wide line's skeleton turn towards a corner of its end: the end
|
||||
// lies where the line was going a width back, and they are put on that line.
|
||||
let back = (width.round() as usize + 2).max(3);
|
||||
let (tip, inner) = (r.pts[at(0)], r.pts[at(back)]);
|
||||
// (Only an end that has left that line: over so few pixels the line's direction is known
|
||||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||||
// better left where it is.)
|
||||
let before = inner - r.pts[at(2 * back)];
|
||||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||||
let d = if wide { before } else { 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 along = d / d.hypot();
|
||||
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
|
||||
if wide {
|
||||
for i in 0..back {
|
||||
r.pts[at(i)] = on_line(r.pts[at(i)]);
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
|
||||
short_by += short / width;
|
||||
told += 1;
|
||||
}
|
||||
let more = reach - width / 2.0;
|
||||
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
|
||||
r.pts[at(0)] = tip + along * more;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,7 +437,7 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len(), square: told > 0 && short_by / f64::from(told) < 0.07 })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
|
||||
@@ -188,7 +188,7 @@ pub(crate) fn fit_loop(
|
||||
across: &dyn Fn(Crack) -> (f64, u32),
|
||||
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
|
||||
style: &Style,
|
||||
) -> Option<SubPath> {
|
||||
) -> Option<(SubPath, bool)> {
|
||||
let pieces = subpixel::outline(l, w, h, across, junction_at);
|
||||
let mut segs: Vec<Cubic> = Vec::new();
|
||||
for piece in &pieces {
|
||||
@@ -206,7 +206,7 @@ pub(crate) fn fit_loop(
|
||||
segs.extend(fit_piece(&piece.pts, &style, true));
|
||||
}
|
||||
}
|
||||
subpath(&segs, true)
|
||||
Some((subpath(&segs, true)?, pieces.iter().any(|p| p.hard)))
|
||||
}
|
||||
|
||||
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
|
||||
|
||||
@@ -25,6 +25,8 @@ 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;
|
||||
/// How far (levels) a pixel beside a gradient may be from the gradient's colour there and join it.
|
||||
const GROW: f64 = 10.0;
|
||||
|
||||
/// A linear gradient a traced path is filled with.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -52,6 +54,12 @@ impl TracedGradient {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels beside pixel `i` of a `w` × `h` image.
|
||||
fn beside(i: usize, w: usize, h: usize) -> impl Iterator<Item = 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()
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
@@ -103,11 +111,8 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
|
||||
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.
|
||||
// The connected smooth pixels from `seed` that `seen` has not: into `members`.
|
||||
let mut flood = |seed: usize, seen: &mut [bool], members: &mut Vec<usize>| {
|
||||
members.clear();
|
||||
stack.push(seed);
|
||||
if let Some(s) = seen.get_mut(seed) {
|
||||
@@ -115,8 +120,7 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
|
||||
}
|
||||
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() {
|
||||
for j in beside(i, w, h) {
|
||||
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;
|
||||
@@ -125,7 +129,26 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if members.len() < MIN_PIXELS || found.len() >= 1000 {
|
||||
};
|
||||
// The areas, largest first: a gradient that noise or a soft rim broke into several is found in
|
||||
// its largest piece and grown over the rest.
|
||||
let mut areas: Vec<(usize, usize)> = vec![];
|
||||
for seed in 0..w * h {
|
||||
if !seen.get(seed).copied().unwrap_or(true) && smooth.get(seed).copied().unwrap_or(false) {
|
||||
flood(seed, &mut seen, &mut members);
|
||||
if members.len() >= MIN_PIXELS {
|
||||
areas.push((members.len(), seed));
|
||||
}
|
||||
}
|
||||
}
|
||||
areas.sort_unstable_by(|a, b| b.cmp(a));
|
||||
seen.fill(false);
|
||||
for (_, seed) in areas {
|
||||
if seen.get(seed).copied().unwrap_or(true) || found.len() >= 1000 {
|
||||
continue;
|
||||
}
|
||||
flood(seed, &mut seen, &mut members);
|
||||
if members.len() < MIN_PIXELS {
|
||||
continue;
|
||||
}
|
||||
// The gradient is read from the area's inside, where there is one: the pixels at its rim
|
||||
@@ -135,16 +158,12 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
|
||||
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();
|
||||
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i, w, h).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) {
|
||||
for j in beside(i, w, h) {
|
||||
if let Some(slot) = depth.get_mut(j).filter(|s| **s == unknown) {
|
||||
*slot = d;
|
||||
next.push(j);
|
||||
@@ -156,12 +175,27 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
|
||||
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 {
|
||||
if let Some((g, mut part)) = fitted {
|
||||
for &i in &part {
|
||||
if let Some(o) = of.get_mut(i) {
|
||||
*o = found.len() as u32;
|
||||
}
|
||||
}
|
||||
// Grown over every pixel next to it that has its colour there: the pixels noise put a
|
||||
// little off, a soft rim, the pieces noise cut off it.
|
||||
while let Some(i) = part.pop() {
|
||||
for j in beside(i, w, h) {
|
||||
let near = px(j).is_some_and(|c| {
|
||||
let own = g.color_at(Point::new((j % w) as f64 + 0.5, (j / w) as f64 + 0.5));
|
||||
(0..3).all(|k| (f64::from(c[k]) - own[k]).abs() <= GROW)
|
||||
});
|
||||
if near && of.get(j).copied() == Some(u32::MAX) {
|
||||
of[j] = found.len() as u32;
|
||||
seen[j] = true;
|
||||
part.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ fn inside_or_near(p: Point, o: &Outer) -> bool {
|
||||
/// 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
|
||||
/// Drawing each path together with those inside it (in their own order) 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();
|
||||
@@ -158,7 +158,7 @@ mod tests {
|
||||
|
||||
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 }
|
||||
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+78
-8
@@ -430,6 +430,14 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> {
|
||||
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
|
||||
}
|
||||
|
||||
/// The enhanced version of built-in preset `name`: the preset with the options the enhanced
|
||||
/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo.
|
||||
/// Gradients stay an option to turn on. Flat Logo, a preset of our own, has none.
|
||||
pub fn enhanced_preset(name: &str) -> Option<TraceParams> {
|
||||
let p = preset(name).filter(|p| p.mode != Mode::Logo)?;
|
||||
Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo"), ..p })
|
||||
}
|
||||
|
||||
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
|
||||
/// lines stroked with it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -446,6 +454,9 @@ pub struct TracedPath {
|
||||
pub shape: Option<TracedShape>,
|
||||
/// Gradients: the linear gradient the path is filled with (`color` is its middle colour).
|
||||
pub gradient: Option<TracedGradient>,
|
||||
/// Create Strokes: the stroke's ends are square (a cap that goes half the width past the
|
||||
/// path's end, cut straight), as the line's ink is; otherwise round.
|
||||
pub square_caps: bool,
|
||||
}
|
||||
|
||||
/// The traced result, bottom-most path first.
|
||||
@@ -455,7 +466,8 @@ pub struct TraceResult {
|
||||
/// 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.
|
||||
/// option). Each path drawn together with those inside it, in the order they have here, paints
|
||||
/// what `paths` paints in its order.
|
||||
pub parents: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
@@ -810,6 +822,36 @@ impl Layers<'_> {
|
||||
shift
|
||||
}
|
||||
|
||||
/// Whether the pixels inside `l` (a loop along pixel edges) all have one label.
|
||||
fn one_colour_within(&self, l: &Loop) -> bool {
|
||||
let mut first = None;
|
||||
let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1)));
|
||||
for y in y0..y1 {
|
||||
// The loop's upright edges that pass this row of pixels, left to right: the pixels
|
||||
// between the first and the second are inside, and so on.
|
||||
let mut xs: Vec<i32> = l
|
||||
.pts
|
||||
.iter()
|
||||
.zip(l.pts.iter().cycle().skip(1))
|
||||
.filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1))
|
||||
.map(|(a, _)| a.0)
|
||||
.collect();
|
||||
xs.sort_unstable();
|
||||
for pair in xs.as_chunks::<2>().0 {
|
||||
for x in pair[0]..pair[1] {
|
||||
let label = usize::try_from(y)
|
||||
.ok()
|
||||
.zip(usize::try_from(x).ok())
|
||||
.and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w));
|
||||
if label.is_none() || *first.get_or_insert(label) != label {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
first.is_some()
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
@@ -846,14 +888,14 @@ impl Layers<'_> {
|
||||
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>, shape: Option<TracedShape>| {
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>, square_caps: bool| {
|
||||
let n = path.anchor_count();
|
||||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||||
return false;
|
||||
}
|
||||
// (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 });
|
||||
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient, square_caps });
|
||||
true
|
||||
};
|
||||
let mut comps = trace_mask(&mask, bw, bh);
|
||||
@@ -866,27 +908,55 @@ impl Layers<'_> {
|
||||
}
|
||||
}
|
||||
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);
|
||||
// (`hard`: some of the outline has no anti-aliasing, and is known less exactly.)
|
||||
let hard = std::cell::Cell::new(false);
|
||||
let fit = |l: &Loop| {
|
||||
let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?;
|
||||
hard.set(hard.get() || is_hard);
|
||||
Some(sp)
|
||||
};
|
||||
let Some(outer) = fit(&comp.outer) else { continue };
|
||||
let mut subs = vec![outer];
|
||||
for hole in &comp.holes {
|
||||
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
|
||||
continue;
|
||||
}
|
||||
subs.extend(fit(hole));
|
||||
let Some(mut sp) = fit(hole) else { continue };
|
||||
// Shapes: a hole that is a shape is that shape, as what fills it is (the two are
|
||||
// one outline: they stay one). Only where one colour fills it: the parts of
|
||||
// several are not that shape, and would no longer meet it.
|
||||
if params.shapes
|
||||
&& area_stroke.is_none()
|
||||
&& self.one_colour_within(hole)
|
||||
&& let Some(exact) = shapes::recognise(&sp, hard.get()).and_then(|shape| shape.path().subpaths.into_iter().next())
|
||||
{
|
||||
// (Which way round each goes: by the area of the polygon through points along it.)
|
||||
let turn = |sp: &vectorcraft_geom::SubPath| {
|
||||
use vectorcraft_geom::ParamCurve;
|
||||
let pts: Vec<_> =
|
||||
(0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect();
|
||||
pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::<f64>()
|
||||
};
|
||||
let mut exact = exact;
|
||||
if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) {
|
||||
exact.reverse();
|
||||
}
|
||||
sp = exact;
|
||||
}
|
||||
subs.push(sp);
|
||||
}
|
||||
// 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),
|
||||
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only, hard.get()),
|
||||
_ => None,
|
||||
};
|
||||
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
|
||||
if !add(path, comp.pixels, area_stroke, shape) {
|
||||
if !add(path, comp.pixels, area_stroke, shape, false) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for line in lines {
|
||||
if !add(line.path, line.pixels, Some(line.width), None) {
|
||||
if !add(line.path, line.pixels, Some(line.width), None, line.square) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +493,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
square_caps: false,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
+105
-56
@@ -37,13 +37,18 @@ impl TracedShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// How near (px) a sharp corner the outline may fall short of it, and by how much more than
|
||||
/// elsewhere: anti-aliasing and blur take that much off a corner.
|
||||
const CORNER: f64 = 3.0;
|
||||
const CORNER_SLACK: f64 = 0.75;
|
||||
|
||||
/// 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> {
|
||||
pub(crate) fn recognise(sp: &SubPath, hard: bool) -> Option<TracedShape> {
|
||||
if !sp.closed || sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
@@ -83,7 +88,8 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
|
||||
// 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);
|
||||
// (An outline without anti-aliasing is only known to about half a pixel.)
|
||||
let tol = tolerance(size) * if hard { 1.5 } else { 1.0 };
|
||||
if size < 6.0 || !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -110,64 +116,107 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
|
||||
}
|
||||
}
|
||||
|
||||
// A rectangle, along its longest straight side (a square has no longer axis to go by).
|
||||
// A rectangle. Its sides run along its longest straight side if it has one, else along the
|
||||
// longer axis of its area (a blurred one may have no side that is straight), or the pixels.
|
||||
let quarter = std::f64::consts::FRAC_PI_2;
|
||||
let folded = |a: f64| a - quarter * (a / quarter).round();
|
||||
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();
|
||||
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)))
|
||||
.map(|s| folded((s.p3 - s.p0).y.atan2((s.p3 - s.p0).x)));
|
||||
for angle in side.into_iter().chain([folded(axis), 0.0]) {
|
||||
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.
|
||||
// (Its half sizes: where its sides lie on average, not where their farthest points do.)
|
||||
let side = |far: f64, upright: bool, other: f64| {
|
||||
let on: Vec<f64> = xy
|
||||
.iter()
|
||||
.map(|p| if upright { (p.0, p.1) } else { (p.1, p.0) })
|
||||
.filter(|&(a, b)| a.abs() >= far - 0.5 && b.abs() <= other - CORNER)
|
||||
.map(|(a, _)| a.abs())
|
||||
.collect();
|
||||
if on.len() >= 4 { on.iter().sum::<f64>() / on.len() as f64 } else { far }
|
||||
};
|
||||
let (hw, hh) = (side(hw, true, hh), side(hh, false, hw));
|
||||
if hw <= 2.0 || hh <= 2.0 {
|
||||
continue;
|
||||
}
|
||||
// How far the outline is from the rectangle with corners of `radius`, at worst; the points
|
||||
// by its corners `slack` nearer than they are.
|
||||
let off = |radius: f64, slack: f64| {
|
||||
let each = xy.iter().map(|&(x, y)| {
|
||||
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()
|
||||
let d = (qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs();
|
||||
if x.abs() > hw - CORNER && y.abs() > hh - CORNER { (d - slack).max(0.0) } else { d }
|
||||
});
|
||||
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() });
|
||||
}
|
||||
each.fold(0.0, f64::max)
|
||||
};
|
||||
let rectangle = |radius: f64| TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() };
|
||||
// Square corners (anti-aliasing and blur take a little off a corner: that is no radius).
|
||||
// (Not of a thin bar: it is all corners, and would pass for a rectangle whatever it is.)
|
||||
if off(0.0, if hw.min(hh) >= 2.0 * CORNER { CORNER_SLACK } else { 0.0 }) <= tol {
|
||||
return Some(rectangle(0.0));
|
||||
}
|
||||
// Rounded ones: the radius that fits best, near the one the area says ((4 − π) r² is missing).
|
||||
let by_area = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt();
|
||||
let radii = (0..=40).map(|k| by_area + (f64::from(k) - 20.0) * 0.15).filter(|r| (CORNER..=hw.min(hh)).contains(r));
|
||||
if let Some((_, radius)) = radii.map(|r| (off(r, 0.0), r)).min_by(|a, b| a.0.total_cmp(&b.0)).filter(|best| best.0 <= tol) {
|
||||
return Some(rectangle(radius));
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
// A regular polygon: going round it, its outline is nearest its middle once a side and farthest
|
||||
// once a corner. The sides are as many as that happens most strongly for, the corners where
|
||||
// it is farthest, the size what the area says.
|
||||
let polar: Vec<(f64, f64)> = pts.iter().map(|&p| ((p - c).hypot(), (p - c).y.atan2((p - c).x))).collect();
|
||||
let weights: Vec<f64> = (0..n).map(|i| (polar[(i + 1) % n].1 - polar[i].1).sin().abs()).collect();
|
||||
let mean = polar.iter().zip(&weights).map(|(p, w)| p.0 * w).sum::<f64>() / weights.iter().sum::<f64>().max(1e-9);
|
||||
let wave = |sides: u32| {
|
||||
polar.iter().zip(&weights).fold((0.0, 0.0), |m, (p, w)| {
|
||||
(m.0 + (p.0 - mean) * w * (f64::from(sides) * p.1).cos(), m.1 + (p.0 - mean) * w * (f64::from(sides) * p.1).sin())
|
||||
})
|
||||
};
|
||||
let sides = (3..=12u32).max_by(|&a, &b| wave(a).0.hypot(wave(a).1).total_cmp(&wave(b).0.hypot(wave(b).1)))?;
|
||||
let (re, im) = wave(sides);
|
||||
let corner = im.atan2(re) / f64::from(sides);
|
||||
// (More exactly: by the outline's own corners, where it has them there.)
|
||||
let step = 2.0 * std::f64::consts::PI / f64::from(sides);
|
||||
let turns: Vec<f64> = sp
|
||||
.anchors
|
||||
.iter()
|
||||
.map(|a| ((a.p - c).y.atan2((a.p - c).x) - corner, (a.p - c).hypot()))
|
||||
.map(|(d, far)| (d - step * (d / step).round(), far))
|
||||
.filter(|&(d, far)| (d * far).abs() <= CORNER && far > 0.9 * mean)
|
||||
.map(|(d, _)| d)
|
||||
.collect();
|
||||
let corner = corner + turns.iter().sum::<f64>() / turns.len().max(1) as f64;
|
||||
let radius = (2.0 * area / (f64::from(sides) * (2.0 * std::f64::consts::PI / f64::from(sides)).sin())).sqrt();
|
||||
let rotation = (corner.to_degrees() + 90.0).rem_euclid(360.0 / f64::from(sides));
|
||||
let shape = TracedShape::Polygon { center: c, radius, sides, rotation };
|
||||
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
|
||||
// Every point of the outline lies on one of the polygon's sides (by its corners, nearly).
|
||||
let side_length = 2.0 * radius * (std::f64::consts::PI / f64::from(sides)).sin();
|
||||
let on_a_side = |p: Point| {
|
||||
let slack = if side_length >= 4.0 * CORNER && ideal.iter().any(|q| q.distance(p) <= CORNER) { CORNER_SLACK } else { 0.0 };
|
||||
(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 + slack
|
||||
})
|
||||
};
|
||||
if radius > 3.0 && 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 },
|
||||
};
|
||||
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 });
|
||||
}
|
||||
return Some(if sides == 4 { square } else { shape });
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -177,7 +226,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is(shape: TracedShape) -> Option<TracedShape> {
|
||||
recognise(shape.path().subpaths.first().unwrap())
|
||||
recognise(shape.path().subpaths.first().unwrap(), false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -221,20 +270,20 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(recognise(&star.subpaths[0], false), 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);
|
||||
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)]), false), 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)]), false), 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);
|
||||
assert_eq!(recognise(&egg.subpaths[0], false), 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);
|
||||
assert_eq!(recognise(&open, false), 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);
|
||||
assert_eq!(recognise(&bad, false), None);
|
||||
}
|
||||
}
|
||||
@@ -1265,3 +1265,33 @@ fn a_long_line_two_degrees_askew_is_not_levelled() {
|
||||
// 200 px at 2° rises 7 px: levelled, the bar would be 8 px tall, not 15.
|
||||
assert!(b.height() > 13.0, "{b:?}");
|
||||
}
|
||||
|
||||
/// Create Strokes: a line cut square at its ends is stroked with square caps, a line with round
|
||||
/// ends with round ones, and either ends where its ink does.
|
||||
#[test]
|
||||
fn stroke_caps_are_square_or_round_as_the_ink_is() {
|
||||
let line = |square: bool| {
|
||||
let img = Raster::from_fn(140, 40, |x, y| {
|
||||
// Anti-aliased by 4 × 4 samples: a bar 8 px thick from x = 20 to 110.
|
||||
let mut inside = 0;
|
||||
for sy in 0..4 {
|
||||
for sx in 0..4 {
|
||||
let (px, py) = (f64::from(x) + (f64::from(sx) + 0.5) / 4.0, f64::from(y) + (f64::from(sy) + 0.5) / 4.0);
|
||||
let d = if square { (py - 20.3).abs().max((px - 65.0).abs() - 41.0) } else { (px.clamp(24.0, 106.0) - px).hypot(py - 20.3) };
|
||||
inside += u32::from(d <= 4.0);
|
||||
}
|
||||
}
|
||||
let v = (255 - inside * 255 / 16) as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let res = trace(&img, &crate::preset("Outline").unwrap());
|
||||
let [only] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
|
||||
let (a, b) = (only.path.subpaths[0].anchors[0].p, only.path.subpaths[0].anchors.last().unwrap().p);
|
||||
// Either cap goes half the width past the path's end: the path runs from 24 to 106.
|
||||
assert!((a.x.min(b.x) - 24.0).abs() < 0.6 && (a.x.max(b.x) - 106.0).abs() < 0.6, "{a:?} {b:?}");
|
||||
assert!((only.stroke.unwrap() - 8.0).abs() < 0.4);
|
||||
only.square_caps
|
||||
};
|
||||
assert!(line(true));
|
||||
assert!(!line(false));
|
||||
}
|
||||
@@ -64,6 +64,7 @@ static ALLOC: Counting = Counting;
|
||||
|
||||
/// Whether `input` is what `preset` is meant for.
|
||||
fn target(preset: &str, input: &Input) -> bool {
|
||||
let preset = preset.strip_suffix(", enhanced").unwrap_or(preset);
|
||||
let classes: &[Class] = match preset {
|
||||
"High Fidelity Photo"
|
||||
| "Low Fidelity Photo"
|
||||
@@ -147,6 +148,7 @@ fn svg_trace(svg: &str) -> TraceResult {
|
||||
stroke: None,
|
||||
shape: None,
|
||||
gradient: None,
|
||||
square_caps: false,
|
||||
})
|
||||
});
|
||||
TraceResult { paths: paths.collect(), palette: vec![[0; 3]], ..Default::default() }
|
||||
@@ -185,12 +187,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 % | 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";
|
||||
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 % | false 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(26));
|
||||
return format!("| refused |{}", " |".repeat(27));
|
||||
}
|
||||
let cells = [
|
||||
stat(&m, |m| m.delta, false, 2),
|
||||
@@ -215,6 +217,7 @@ fn row(runs: &[&Run]) -> String {
|
||||
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.false_shapes, false, 2),
|
||||
stat(&m, |m| m.grouped, false, 0),
|
||||
stat(&m, |m| m.gradients, false, 1),
|
||||
stat(&m, |m| m.ramp_found, false, 0),
|
||||
@@ -264,6 +267,11 @@ fn report() -> String {
|
||||
] {
|
||||
presets.push((name, TraceParams { gradients, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
// Each preset's enhanced version, on that preset's own inputs.
|
||||
for name in vectorcraft_trace::PRESET_NAMES {
|
||||
let enhanced: &'static str = Box::leak(format!("{name}, enhanced").into_boxed_str());
|
||||
presets.extend(vectorcraft_trace::enhanced_preset(name).map(|p| (enhanced, p)));
|
||||
}
|
||||
let inputs = inputs();
|
||||
let drawn = draw_all(&inputs);
|
||||
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew | Class::Plain);
|
||||
|
||||
@@ -59,7 +59,10 @@ pub fn fidelity(src: &Raster, out: &Raster) -> (f64, f64) {
|
||||
fn painted(tp: &TracedPath) -> BezPath {
|
||||
let bez = tp.path.to_bezpath();
|
||||
match tp.stroke {
|
||||
Some(width) => kurbo::stroke(bez, &Stroke::new(width), &StrokeOpts::default(), 0.05),
|
||||
Some(width) => {
|
||||
let cap = if tp.square_caps { kurbo::Cap::Square } else { kurbo::Cap::Round };
|
||||
kurbo::stroke(bez, &Stroke::new(width).with_caps(cap), &StrokeOpts::default(), 0.05)
|
||||
}
|
||||
None => bez,
|
||||
}
|
||||
}
|
||||
@@ -194,6 +197,8 @@ pub struct Metrics {
|
||||
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,
|
||||
/// Shapes: paths made a shape that lie more than 0.6 px off the art.
|
||||
pub false_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
|
||||
@@ -209,21 +214,25 @@ pub struct Metrics {
|
||||
pub outside: f64,
|
||||
}
|
||||
|
||||
/// The order a grouped result is painted in: each path, then those inside it.
|
||||
/// The order a grouped result is painted in: each path together with those inside it, in the
|
||||
/// order they were traced in (as the app builds its groups).
|
||||
fn draw_order(res: &TraceResult) -> Vec<usize> {
|
||||
fn draw(i: usize, inside: &[Vec<usize>], order: &mut Vec<usize>) {
|
||||
let below = inside[i].partition_point(|&c| c < i);
|
||||
inside[i][..below].iter().for_each(|&c| draw(c, inside, order));
|
||||
order.push(i);
|
||||
inside[i][below..].iter().for_each(|&c| draw(c, inside, order));
|
||||
}
|
||||
let mut inside = vec![vec![]; res.paths.len()];
|
||||
let mut stack = vec![];
|
||||
for i in (0..res.paths.len()).rev() {
|
||||
let mut roots = vec![];
|
||||
for i in 0..res.paths.len() {
|
||||
match res.parents.get(i).copied().flatten() {
|
||||
Some(p) => inside[p].insert(0, i),
|
||||
None => stack.push(i),
|
||||
Some(p) => inside[p].push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
}
|
||||
let mut order = vec![];
|
||||
while let Some(i) = stack.pop() {
|
||||
order.push(i);
|
||||
stack.extend(inside[i].iter().rev());
|
||||
}
|
||||
roots.into_iter().for_each(|i| draw(i, &inside, &mut order));
|
||||
order
|
||||
}
|
||||
|
||||
@@ -231,7 +240,7 @@ fn draw_order(res: &TraceResult) -> Vec<usize> {
|
||||
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();
|
||||
// Painted as the result is grouped: each path, then those inside it.
|
||||
// Painted as the result is grouped.
|
||||
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));
|
||||
@@ -271,11 +280,16 @@ 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);
|
||||
let mut false_shapes = if params.shapes { 0.0 } else { nan };
|
||||
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)));
|
||||
(geo_mean, geo_max) = traced.distance_to(&truth);
|
||||
// Shapes that are not the art's: a path made a shape that strays more than 0.6 px from it.
|
||||
false_shapes =
|
||||
res.paths.iter().filter(|tp| tp.shape.is_some() && Outline::new([&tp.path.to_bezpath()], None).distance_to(&truth).1 > 0.6).count()
|
||||
as f64;
|
||||
miss_max = truth.distance_to(&traced).1;
|
||||
|
||||
let true_corners = input.scaled_corners(size);
|
||||
@@ -378,6 +392,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
ramp_angle,
|
||||
ramp_off,
|
||||
shapes: shapes_found,
|
||||
false_shapes,
|
||||
centre_off,
|
||||
width_off,
|
||||
stroke_anchors,
|
||||
|
||||
+236
-18
@@ -345,14 +345,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
match k.pixel {
|
||||
// Document pixels: placed over the document rect they cover, turning with the view.
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
let q = xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64));
|
||||
let mut mesh = egui::Mesh::with_texture(tex.id());
|
||||
for (pos, uv) in q.into_iter().zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(Shape::mesh(mesh));
|
||||
textured_quad(&painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0));
|
||||
}
|
||||
None if (k.rot - v.rotation).abs() < 1e-9 => {
|
||||
// Reproject the last frame if it was rendered for a different view.
|
||||
@@ -361,7 +354,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let b = xf.to_screen(old.to_doc(rect.max));
|
||||
painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {}
|
||||
// The view turned after the last frame was rendered, and the worker renders the frame at
|
||||
// the new angle. Until it arrives, the last frame is drawn turned into this view: each
|
||||
// corner goes where this view puts the document point that the corner showed.
|
||||
None => {
|
||||
let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() };
|
||||
let q = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c)));
|
||||
textured_quad(&painter, tex.id(), &q, (1.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
|
||||
@@ -986,10 +986,15 @@ pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid";
|
||||
/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose
|
||||
/// cells stay `cell` screen points at every zoom and turn with a rotated view.
|
||||
fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
// Two cells per texture repeat.
|
||||
let per = (2.0 * cell as f64 / zoom).max(1e-9);
|
||||
let (u, v) = ((ab.width() / per) as f32, (ab.height() / per) as f32);
|
||||
textured_quad(p, tex, quad, ((ab.width() / per) as f32, (ab.height() / per) as f32));
|
||||
}
|
||||
|
||||
/// One quad drawn with `tex`: its corners `quad` (from the top left, clockwise) show the texture
|
||||
/// from (0, 0) to (`u`, `v`).
|
||||
fn textured_quad(p: &egui::Painter, tex: egui::TextureId, quad: &[Pos2], (u, v): (f32, f32)) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE });
|
||||
}
|
||||
@@ -1518,25 +1523,86 @@ fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f
|
||||
}
|
||||
|
||||
/// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story.
|
||||
fn hidden_chars_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
/// The marks of each text object are kept from one frame to the next ([`HiddenChars`]).
|
||||
fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let clip = p.clip_rect();
|
||||
let color = Color32::from_rgb(0x4f, 0x9d, 0xff);
|
||||
let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0));
|
||||
st.doc.walk(|n| {
|
||||
let kept = &mut app.canvas.hidden_chars;
|
||||
// `last` holds the marks of the last frame that showed them. The marks of the objects this
|
||||
// frame doesn't draw are dropped at the end.
|
||||
let mut last = std::mem::take(&mut kept.marks);
|
||||
// The inputs are read before any layout: when fonts load during this frame, the next frame
|
||||
// lays all type out again.
|
||||
let inputs = (vectorcraft_text::FontDb::global().generation(), vectorcraft_text::hyphenation_exceptions());
|
||||
if kept.inputs != inputs {
|
||||
last.clear();
|
||||
kept.inputs = inputs;
|
||||
}
|
||||
// `walk` visits each node's `Arc` in the order `Document::walk` visits the nodes (depth first,
|
||||
// in paint order).
|
||||
fn walk(nodes: &[std::sync::Arc<Node>], f: &mut impl FnMut(&std::sync::Arc<Node>)) {
|
||||
for n in nodes {
|
||||
f(n);
|
||||
if let Some(children) = n.children() {
|
||||
walk(children, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(&st.doc.layers, &mut |n| {
|
||||
let NodeKind::Text(tx) = &n.kind else { return };
|
||||
if !n.visible || n.geometric_bounds().is_none_or(|b| !xf.rect_to_screen(b).intersects(clip)) {
|
||||
return;
|
||||
}
|
||||
let marks = match last.remove(&n.id) {
|
||||
Some((was, marks)) if std::sync::Arc::ptr_eq(&was, n) => marks,
|
||||
_ => std::sync::Arc::new(HiddenMarks::of(tx)),
|
||||
};
|
||||
for &at in &marks.spaces {
|
||||
p.circle_filled(xf.to_screen(at), 1.2, color);
|
||||
}
|
||||
for &(at, glyph) in &marks.ends {
|
||||
p.text(xf.to_screen(at), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color);
|
||||
}
|
||||
kept.marks.insert(n.id, (n.clone(), marks));
|
||||
});
|
||||
}
|
||||
|
||||
/// The Show Hidden Characters marks of the text objects drawn in the last frame that showed them
|
||||
/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an
|
||||
/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the
|
||||
/// fonts or the hyphenation exceptions change.
|
||||
#[derive(Default)]
|
||||
pub struct HiddenChars {
|
||||
/// The fonts' generation ([`vectorcraft_text::FontDb::generation`]) and the hyphenation
|
||||
/// exceptions the marks were laid out with.
|
||||
inputs: (u64, String),
|
||||
/// Each text object drawn, by id: the node it was laid out from and its marks.
|
||||
marks: std::collections::HashMap<vectorcraft_doc::NodeId, (std::sync::Arc<Node>, std::sync::Arc<HiddenMarks>)>,
|
||||
}
|
||||
|
||||
/// One text object's marks in document space: a dot over each space, and ¶ or # after the lines
|
||||
/// that end a paragraph or the story. Type on a path has none.
|
||||
#[derive(Default)]
|
||||
struct HiddenMarks {
|
||||
spaces: Vec<Point>,
|
||||
ends: Vec<(Point, &'static str)>,
|
||||
}
|
||||
|
||||
impl HiddenMarks {
|
||||
/// Lay out `tx` and place its marks.
|
||||
fn of(tx: &vectorcraft_doc::TextObject) -> Self {
|
||||
let mut marks = Self::default();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx);
|
||||
if lay.on_path {
|
||||
return;
|
||||
return marks;
|
||||
}
|
||||
let text = tx.plain_text();
|
||||
let to = |q: Point| xf.to_screen(tx.xf * q);
|
||||
let to = |q: Point| tx.xf * q;
|
||||
for g in &lay.glyphs {
|
||||
if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) {
|
||||
p.circle_filled(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0)), 1.2, color);
|
||||
marks.spaces.push(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0)));
|
||||
}
|
||||
}
|
||||
for (i, line) in lay.lines.iter().enumerate() {
|
||||
@@ -1550,10 +1616,11 @@ fn hidden_chars_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
("", false)
|
||||
};
|
||||
if at {
|
||||
p.text(to(Point::new(line.x1 + 1.0, line.baseline)), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color);
|
||||
marks.ends.push((to(Point::new(line.x1 + 1.0, line.baseline)), glyph));
|
||||
}
|
||||
}
|
||||
});
|
||||
marks
|
||||
}
|
||||
}
|
||||
|
||||
/// Text threads of the selected frames: a line from each frame's out port (bottom right) to the
|
||||
@@ -3150,6 +3217,74 @@ mod tests {
|
||||
assert!(!app.canvas.moved);
|
||||
}
|
||||
|
||||
/// When the view turns while the worker renders the frame at the new angle (Reset Rotate View on
|
||||
/// a heavy document), the canvas draws the last frame turned until that frame arrives: each
|
||||
/// corner goes where the new view puts the document point that the corner showed. The new frame
|
||||
/// is then drawn unturned. In Pixel Preview the last frame's pixels stay over the document rect
|
||||
/// they cover.
|
||||
#[test]
|
||||
fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
let ctx = egui::Context::default();
|
||||
shapes(&mut app, &ctx); // the first frame fits the view to the artboard
|
||||
app.view_mut().unwrap().rotation = 30.0;
|
||||
shapes(&mut app, &ctx);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let old = Xf::new(rect, app.view().unwrap());
|
||||
// `art` runs one frame and finds the mesh that draws the art's texture.
|
||||
let art = |app: &mut VectorcraftApp| {
|
||||
let s = shapes(app, &ctx);
|
||||
let tex = app.canvas.texture.as_ref().unwrap().id();
|
||||
s.into_iter().find_map(|s| match s {
|
||||
Shape::Mesh(m) if m.texture_id == tex => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// With a worker and a last frame that took 50 ms, the next frame renders on the worker.
|
||||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||||
app.canvas.last_ms = 50.0;
|
||||
app.run("view.rotateReset", json!({})).unwrap();
|
||||
let mesh = art(&mut app).expect("the last frame, turned");
|
||||
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.rot == 0.0), "sent to the worker");
|
||||
let new = Xf::new(rect, app.view().unwrap());
|
||||
let corners = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()];
|
||||
assert_eq!(mesh.vertices.len(), 4);
|
||||
for ((v, corner), uv) in mesh.vertices.iter().zip(corners).zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
|
||||
let at = new.to_screen(old.to_doc(corner));
|
||||
assert!((v.pos - at).length() < 0.01 && v.uv == uv, "{v:?}: texture corner {uv:?} at {at:?}");
|
||||
}
|
||||
// The point where the last frame drew the rectangle's top-left corner lands where the new view puts that corner.
|
||||
let p = Point::new(100.0, 50.0);
|
||||
let uv = (old.to_screen(p) - rect.min) / rect.size();
|
||||
let [a, b, _, d] = [0, 1, 2, 3].map(|i| mesh.vertices[i].pos);
|
||||
assert!((a + (b - a) * uv.x + (d - a) * uv.y - new.to_screen(p)).length() < 0.01, "the rectangle's corner");
|
||||
// The worker's frame at 0° then covers the canvas unturned.
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while app.canvas.key.as_ref().is_some_and(|k| k.rot != 0.0) && std::time::Instant::now() < until {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
shapes(&mut app, &ctx);
|
||||
}
|
||||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.rot == 0.0), "the worker's frame arrives");
|
||||
let b = art(&mut app).expect("the worker's frame").calc_bounds();
|
||||
assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}");
|
||||
// In Pixel Preview the last frame's pixels are drawn over the document rect they cover.
|
||||
app.canvas.worker = None;
|
||||
app.ui.view.pixel_preview = true;
|
||||
app.view_mut().unwrap().zoom = 8.0;
|
||||
shapes(&mut app, &ctx);
|
||||
let [x0, y0, ..] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered");
|
||||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||||
app.canvas.last_ms = 50.0;
|
||||
app.view_mut().unwrap().rotation = 30.0;
|
||||
let mesh = art(&mut app).expect("the last frame's pixels");
|
||||
let at = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(x0 as f64, y0 as f64));
|
||||
assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos);
|
||||
app.canvas.worker = None;
|
||||
}
|
||||
|
||||
/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel
|
||||
/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the
|
||||
/// canvas always does out of Pixel Preview.
|
||||
@@ -3545,6 +3680,89 @@ mod tests {
|
||||
assert!(widget(&mut app).is_empty(), "no bounding box, no widget");
|
||||
}
|
||||
|
||||
/// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`.
|
||||
fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec<Shape> {
|
||||
let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| hidden_chars_overlay(app, ui.painter(), xf));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.into_iter().map(|c| c.shape).collect()
|
||||
}
|
||||
|
||||
/// Show Hidden Characters lays out type once: a frame keeps the marks of the frame before, and
|
||||
/// after an edit only the object edited is laid out again.
|
||||
#[test]
|
||||
fn show_hidden_characters_lays_out_only_type_that_changed() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ids: Vec<_> = (0..3)
|
||||
.map(|i| app.session.execute("text.create", &json!({"x": 20, "y": 40 + 80 * i, "text": "a b\nc d"})).unwrap()["id"].as_u64().unwrap())
|
||||
.map(vectorcraft_doc::NodeId)
|
||||
.collect();
|
||||
let ctx = egui::Context::default();
|
||||
let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 };
|
||||
// `frame` draws one frame and returns each object's marks and whether that frame found the
|
||||
// fonts and hyphenation exceptions of the frame before.
|
||||
let frame = |app: &mut VectorcraftApp| {
|
||||
let inputs = app.canvas.hidden_chars.inputs.clone();
|
||||
hidden_chars(app, &ctx, &xf);
|
||||
let kept = &app.canvas.hidden_chars;
|
||||
(kept.inputs == inputs, ids.iter().map(|id| kept.marks[id].1.clone()).collect::<Vec<_>>())
|
||||
};
|
||||
// `frames` draws a frame, runs `edit` and draws the next frame. Tests running alongside add
|
||||
// fonts and set hyphenation exceptions, which lays all type out again: `frames` draws both
|
||||
// frames again until neither the fonts nor the exceptions changed in between.
|
||||
let frames = |app: &mut VectorcraftApp, edit: &dyn Fn(&mut VectorcraftApp)| {
|
||||
(0..100)
|
||||
.find_map(|_| {
|
||||
let (_, before) = frame(app);
|
||||
edit(app);
|
||||
let (same, after) = frame(app);
|
||||
same.then_some((before, after))
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let same = |a: &std::sync::Arc<HiddenMarks>, b: &std::sync::Arc<HiddenMarks>| std::sync::Arc::ptr_eq(a, b);
|
||||
let (before, after) = frames(&mut app, &|_| {});
|
||||
assert!(before.iter().zip(&after).all(|(a, b)| same(a, b)), "a frame lays out no type");
|
||||
let (before, after) = frames(&mut app, &|app| {
|
||||
app.session.execute("text.setText", &json!({"id": ids[1].0, "text": "a b c"})).unwrap();
|
||||
});
|
||||
assert!(!same(&before[1], &after[1]), "the edited object is laid out again");
|
||||
assert!(same(&before[0], &after[0]) && same(&before[2], &after[2]), "the others are not");
|
||||
}
|
||||
|
||||
/// The marks Show Hidden Characters keeps follow the type: after an edit, a zoom and a font
|
||||
/// that arrives, a frame draws what a new overlay draws, and the marks moved.
|
||||
#[test]
|
||||
fn show_hidden_characters_follows_edits_zooms_and_fonts() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
// The type is set in Bold of a family that is not installed and whose name starts with a
|
||||
// dot; the font menus do not list such names. Source Sans 3 Bold stands in for it until the
|
||||
// family arrives below with a Regular face only.
|
||||
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four", "font": ".Hidden Marks", "style": "Bold"});
|
||||
let id = app.session.execute("text.create", &text).unwrap()["id"].clone();
|
||||
let ctx = egui::Context::default();
|
||||
let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 };
|
||||
// `drawn` draws a frame with what the overlay kept and checks it against a new overlay.
|
||||
let drawn = |app: &mut VectorcraftApp, xf: &Xf| {
|
||||
let kept = hidden_chars(app, &ctx, xf);
|
||||
app.canvas.hidden_chars = Default::default();
|
||||
assert_eq!(hidden_chars(app, &ctx, xf), kept, "as a new overlay draws them");
|
||||
kept
|
||||
};
|
||||
let start = drawn(&mut app, &xf);
|
||||
assert!(start.len() > 2, "{start:?}");
|
||||
app.session.execute("text.setText", &json!({"id": id, "text": "one two three\nfour"})).unwrap();
|
||||
let edited = drawn(&mut app, &xf);
|
||||
assert_ne!(edited, start, "the edit moved the marks");
|
||||
xf.zoom = 2.0;
|
||||
let zoomed = drawn(&mut app, &xf);
|
||||
assert_ne!(zoomed, edited, "zoomed in");
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(vectorcraft_testkit::fonts::renamed(".Hidden Marks")) > 0, "the family is new");
|
||||
assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived");
|
||||
}
|
||||
|
||||
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
|
||||
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
|
||||
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
|
||||
|
||||
@@ -32,6 +32,31 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::canvas::Xf;
|
||||
|
||||
/// Synthetic pointer input advances one event per frame: keep gestures and backlog modest.
|
||||
pub(crate) const MAX_DRAG_STEPS: u64 = 256;
|
||||
pub(crate) const MAX_CLICK_COUNT: u64 = 16;
|
||||
pub(crate) const MAX_SYNTHETIC_EVENTS: usize = 1024;
|
||||
pub(crate) const MAX_CONTROL_REQUESTS_PER_FRAME: usize = 64;
|
||||
|
||||
fn input_count(p: &Value, key: &str, default: u64, max: u64) -> Result<usize, String> {
|
||||
let n = match p.get(key) {
|
||||
None => default,
|
||||
Some(v) => v.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))?,
|
||||
};
|
||||
if n > max {
|
||||
return Err(format!("`{key}` must be at most {max}"));
|
||||
}
|
||||
// Preserve legacy zero behaviour: both a zero count and zero steps still perform once.
|
||||
usize::try_from(n.max(1)).map_err(|_| format!("`{key}` is out of range"))
|
||||
}
|
||||
|
||||
fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), String> {
|
||||
if additional > MAX_SYNTHETIC_EVENTS.saturating_sub(app.synthetic.len()) {
|
||||
return Err(format!("synthetic input queue limit ({MAX_SYNTHETIC_EVENTS} events); retry after pending input drains"));
|
||||
}
|
||||
app.synthetic.try_reserve(additional).map_err(|e| format!("cannot reserve synthetic input: {e}"))
|
||||
}
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
pub struct ControlRequest {
|
||||
@@ -193,6 +218,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
|
||||
if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) {
|
||||
return err(e);
|
||||
}
|
||||
let m = modifiers(p);
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
// Typed text comes with the press, so it shares the key's frame and modifiers.
|
||||
@@ -204,12 +232,34 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.move" => {
|
||||
if let Err(e) = reserve_synthetic(app, 1) {
|
||||
return err(e);
|
||||
}
|
||||
let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32);
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.click" | "ui.drag" => {
|
||||
// Validate the entire expansion before appending even the initial move/press.
|
||||
let steps = if req.method == "ui.drag" {
|
||||
match input_count(p, "steps", 8, MAX_DRAG_STEPS) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return err(e),
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else {
|
||||
return err("synthetic input event count is out of range");
|
||||
};
|
||||
if let Err(e) = reserve_synthetic(app, events) {
|
||||
return err(e);
|
||||
}
|
||||
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
let button = match s("button") {
|
||||
@@ -222,14 +272,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
|
||||
if req.method == "ui.drag" {
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).max(1);
|
||||
for i in 1..=steps {
|
||||
let t = i as f32 / steps as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
|
||||
}
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
for _ in 1..count {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
|
||||
@@ -238,6 +286,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.wheel" => {
|
||||
if let Err(e) = reserve_synthetic(app, 2) {
|
||||
return err(e);
|
||||
}
|
||||
// Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways
|
||||
// (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them.
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
@@ -250,6 +301,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.text" => {
|
||||
if let Err(e) = reserve_synthetic(app, 1) {
|
||||
return err(e);
|
||||
}
|
||||
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
@@ -410,6 +464,126 @@ mod tests {
|
||||
use super::{ControlRequest, Outcome, handle};
|
||||
use crate::{Services, VectorcraftApp};
|
||||
|
||||
fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value {
|
||||
let (req, _) = ControlRequest::new(method, params);
|
||||
let Outcome::Done(r) = handle(app, &egui::Context::default(), &req) else { panic!("expected immediate reply") };
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_input_rejects_excessive_counts_before_mutation() {
|
||||
// Only modest over-limit values on the baseline: never exercise an unbounded loop.
|
||||
for (method, params) in
|
||||
[("ui.click", json!({"count": 17})), ("ui.drag", json!({"steps": 257})), ("ui.drag", json!({"steps": 2, "count": 17}))]
|
||||
{
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.synthetic.push(egui::Event::Text("already queued".into()));
|
||||
let before = format!("{:?}", app.synthetic);
|
||||
let r = input_request(&mut app, method, params);
|
||||
assert_eq!(r["ok"], false, "oversized input must be rejected: {r}");
|
||||
assert_eq!(format!("{:?}", app.synthetic), before, "rejection must preserve the entire queue");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_input_respects_existing_queue_budget() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1023];
|
||||
let before = format!("{:?}", app.synthetic);
|
||||
let r = input_request(&mut app, "ui.click", json!({}));
|
||||
assert_eq!(r["ok"], false, "a whole click must fit before any part is appended");
|
||||
assert_eq!(format!("{:?}", app.synthetic), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_input_yields_control_channel_after_frame_budget() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
|
||||
let mut replies = vec![];
|
||||
for _ in 0..65 {
|
||||
let (req, reply) = ControlRequest::new("ui.move", json!({"x": 1, "y": 2}));
|
||||
tx.send(req).unwrap();
|
||||
replies.push(reply);
|
||||
}
|
||||
app.drain_control(&egui::Context::default());
|
||||
assert_eq!(replies.iter().filter(|r| r.try_recv().is_ok()).count(), 64);
|
||||
assert_eq!(app.synthetic.len(), 64);
|
||||
app.drain_control(&egui::Context::default());
|
||||
assert_eq!(replies.last().unwrap().try_recv().unwrap()["ok"], true);
|
||||
assert_eq!(app.synthetic.len(), 65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_input_rejects_invalid_and_extreme_counts() {
|
||||
for value in [json!(u64::MAX), json!(-1), json!(1.5), json!("2"), Value::Null] {
|
||||
for (method, key) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.synthetic.push(egui::Event::Text("pending".into()));
|
||||
let before = format!("{:?}", app.synthetic);
|
||||
let mut params = json!({});
|
||||
params[key] = value.clone();
|
||||
let r = input_request(&mut app, method, params);
|
||||
assert_eq!(r["ok"], false, "{method}/{key}: {r}");
|
||||
assert!(r["error"].as_str().unwrap().contains(key));
|
||||
assert_eq!(format!("{:?}", app.synthetic), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_input_accepts_boundary_gestures_and_preserves_event_order() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
let r = input_request(
|
||||
&mut app,
|
||||
"ui.drag",
|
||||
json!({"x": 2, "y": 3, "toX": 20, "toY": 30,
|
||||
"steps": 256, "count": 16, "button": "right", "shift": true}),
|
||||
);
|
||||
assert_eq!(r["ok"], true);
|
||||
assert_eq!(app.synthetic.len(), 289);
|
||||
assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(2.0, 3.0)));
|
||||
assert!(
|
||||
matches!(app.synthetic.get(1), Some(egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Secondary, modifiers, .. }) if modifiers.shift)
|
||||
);
|
||||
assert!(matches!(app.synthetic.get(257), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(20.0, 30.0)));
|
||||
assert!(matches!(app.synthetic.get(258), Some(egui::Event::PointerButton { pressed: false, .. })));
|
||||
assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, pos, .. }) if *pos == egui::pos2(20.0, 30.0)));
|
||||
for (method, params, events) in [
|
||||
("ui.click", json!({}), 3),
|
||||
("ui.click", json!({"count": 2}), 5),
|
||||
("ui.drag", json!({}), 11),
|
||||
("ui.drag", json!({"steps": 0, "count": 0}), 4),
|
||||
] {
|
||||
app.synthetic.clear();
|
||||
assert_eq!(input_request(&mut app, method, params)["ok"], true);
|
||||
assert_eq!(app.synthetic.len(), events);
|
||||
}
|
||||
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(0.0, 0.0)); 1021];
|
||||
assert_eq!(input_request(&mut app, "ui.click", json!({}))["ok"], true);
|
||||
assert_eq!(app.synthetic.len(), 1024, "the exact queue boundary is permitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_input_budget_covers_every_synthetic_method() {
|
||||
for (method, params) in [
|
||||
("ui.key", json!({"key": "Enter", "text": "x"})),
|
||||
("ui.move", json!({})),
|
||||
("ui.wheel", json!({"dy": 1})),
|
||||
("ui.text", json!({"text": "hello"})),
|
||||
("ui.click", json!({})),
|
||||
("ui.drag", json!({"steps": 1})),
|
||||
] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1024];
|
||||
let before = format!("{:?}", app.synthetic);
|
||||
let r = input_request(&mut app, method, params);
|
||||
assert_eq!(r["ok"], false, "{method}: {r}");
|
||||
assert_eq!(format!("{:?}", app.synthetic), before);
|
||||
app.synthetic.clear();
|
||||
assert_eq!(input_request(&mut app, method, json!({"key": "Enter", "steps": 1}))["ok"], true, "queue space becomes usable after draining");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked
|
||||
/// for, at the scale asked for; the first without one.
|
||||
#[test]
|
||||
|
||||
@@ -1428,6 +1428,7 @@ lighting Light Světlo
|
||||
Black and White Černobílé
|
||||
Corners Rohy
|
||||
Auto Grouping Automatické seskupení
|
||||
Enhanced Vylepšené
|
||||
Ignore Color Ignorovat barvu
|
||||
Ignore White Ignorovat bílou
|
||||
Palette: Paleta:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1283,6 +1283,7 @@
|
||||
Ignore Ignorieren
|
||||
Ignore Artboards (all the art as one page) Zeichenflächen ignorieren (gesamte Grafik als eine Seite)
|
||||
Auto Grouping Automatisch gruppieren
|
||||
Enhanced Erweitert
|
||||
Ignore Color Farbe ignorieren
|
||||
Ignore White Weiß ignorieren
|
||||
Palette: Palette:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1301,6 +1301,7 @@
|
||||
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
|
||||
Enhanced Mejorado
|
||||
Ignore Color Ignorar color
|
||||
Ignore White Ignorar blanco
|
||||
Palette: Paleta:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1300,6 +1300,7 @@
|
||||
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
|
||||
Enhanced Amélioré
|
||||
Ignore Color Ignorer la couleur
|
||||
Ignore White Ignorer le blanc
|
||||
Palette: Palette :
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1662,6 +1662,7 @@
|
||||
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
|
||||
Enhanced Továbbfejlesztett
|
||||
Ignore Color Szín figyelmen kívül hagyása
|
||||
Ignore White Fehér figyelmen kívül hagyása
|
||||
Image Kép
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1300,6 +1300,7 @@
|
||||
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
|
||||
Enhanced Avanzato
|
||||
Ignore Color Ignora colore
|
||||
Ignore White Ignora bianco
|
||||
Palette: Palette:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1539,6 +1539,7 @@
|
||||
Ignore 無視
|
||||
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
|
||||
Auto Grouping 自動グループ化
|
||||
Enhanced 拡張
|
||||
Ignore Color カラーを無視
|
||||
Ignore White 白を無視
|
||||
Palette: パレット:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1534,6 +1534,7 @@
|
||||
Ignore 무시
|
||||
Ignore Artboards (all the art as one page) 아트보드 무시 (모든 아트웍을 한 페이지로)
|
||||
Auto Grouping 자동 그룹화
|
||||
Enhanced 향상됨
|
||||
Ignore Color 색상 무시
|
||||
Ignore White 흰색 무시
|
||||
Palette: 팔레트:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1378,6 +1378,7 @@ lighting Light Światło
|
||||
Black and White Czarno-biały
|
||||
Corners Narożniki
|
||||
Auto Grouping Automatyczne grupowanie
|
||||
Enhanced Ulepszone
|
||||
Ignore Color Ignoruj kolor
|
||||
Ignore White Ignoruj biel
|
||||
Palette: Paleta:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1298,6 +1298,7 @@
|
||||
Ignore Игнорировать
|
||||
Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница)
|
||||
Auto Grouping Автогруппировка
|
||||
Enhanced Улучшенный
|
||||
Ignore Color Игнорировать цвет
|
||||
Ignore White Игнорировать белый
|
||||
Palette: Палитра:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1188,7 +1188,9 @@ pub fn message_literals() -> std::collections::BTreeSet<String> {
|
||||
if name != "tests" {
|
||||
stack.push(path);
|
||||
}
|
||||
} else if name.ends_with(".rs") && !name.starts_with("tests") {
|
||||
} else if name.ends_with(".rs") && !name.starts_with("tests") && !(d == crates.join("ui-egui").join("src") && name == "control.rs") {
|
||||
// Control replies are English protocol data, never status-bar messages
|
||||
// (see i18n's module contract). Engine messages are scanned in their own crates.
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let code = without_test_module(&text);
|
||||
for marker in MESSAGE_MARKERS {
|
||||
|
||||
@@ -1294,6 +1294,7 @@
|
||||
Ignore Ігнорувати
|
||||
Ignore Artboards (all the art as one page) Ігнорувати монтажні області (весь малюнок як одна сторінка)
|
||||
Auto Grouping Автогрупування
|
||||
Enhanced Покращений
|
||||
Ignore Color Ігнорувати колір
|
||||
Ignore White Ігнорувати білий
|
||||
Palette: Палітра:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 713 and column 40.
|
@@ -1491,6 +1491,7 @@
|
||||
Ignore Bỏ qua
|
||||
Ignore Artboards (all the art as one page) Bỏ qua bảng vẽ (toàn bộ bản vẽ thành một trang)
|
||||
Auto Grouping Tự động nhóm
|
||||
Enhanced Nâng cao
|
||||
Ignore Color Bỏ qua màu
|
||||
Ignore White Bỏ qua màu trắng
|
||||
Image Hình ảnh
|
||||
|
||||
|
@@ -1246,6 +1246,7 @@
|
||||
Ignore 忽略
|
||||
Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页)
|
||||
Auto Grouping 自动编组
|
||||
Enhanced 增强
|
||||
Ignore Color 忽略颜色
|
||||
Ignore White 忽略白色
|
||||
Palette: 调板:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1276,6 +1276,7 @@
|
||||
Ignore 略過
|
||||
Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面)
|
||||
Auto Grouping 自動群組
|
||||
Enhanced 增強
|
||||
Ignore Color 忽略顏色
|
||||
Ignore White 忽略白色
|
||||
Palette: 調色盤:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -282,6 +282,9 @@ pub struct CanvasCache {
|
||||
pub slices: Option<SliceCache>,
|
||||
/// The print tiling's pages for (document uid, revision) (View → Show Print Tiling).
|
||||
pub print_tiling: Option<PrintTilingCache>,
|
||||
/// The marks Type → Show Hidden Characters drew on each text object in the last frame that
|
||||
/// showed them.
|
||||
pub hidden_chars: canvas::HiddenChars,
|
||||
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
|
||||
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
|
||||
/// [`VectorcraftApp::selection_bounds`] for (document uid, revision).
|
||||
@@ -472,6 +475,7 @@ impl VectorcraftApp {
|
||||
worker_started: false,
|
||||
slices: None,
|
||||
print_tiling: None,
|
||||
hidden_chars: Default::default(),
|
||||
selection_box: None,
|
||||
selection_bounds: None,
|
||||
cursors: Default::default(),
|
||||
@@ -759,7 +763,10 @@ impl VectorcraftApp {
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
let mut handled = 0;
|
||||
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
|
||||
let Ok(req) = rx.try_recv() else { break };
|
||||
handled += 1;
|
||||
let reply = req.reply.clone();
|
||||
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
|
||||
let roots = self.automation_roots.clone();
|
||||
@@ -783,6 +790,10 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME {
|
||||
// Leave later requests on the channel and schedule another frame to serve them.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.control_rx = Some(rx);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,11 +68,12 @@ struct TraceUi {
|
||||
synced: Value,
|
||||
}
|
||||
|
||||
fn presets(app: &mut VectorcraftApp) -> Vec<(String, Value)> {
|
||||
/// Each preset: its name, its parameters, and those of its enhanced version (null if it has none).
|
||||
fn presets(app: &mut VectorcraftApp) -> Vec<(String, Value, Value)> {
|
||||
let v = app.session.execute("imageTrace.presets", &json!({})).unwrap_or_default();
|
||||
v["presets"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().map(|p| (p["name"].as_str().unwrap_or("").to_string(), p["params"].clone())).collect())
|
||||
.map(|a| a.iter().map(|p| (p["name"].as_str().unwrap_or("").to_string(), p["params"].clone(), p["enhanced"].clone())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ fn preset_label(name: &str) -> &str {
|
||||
|
||||
fn preset_list(app: &mut VectorcraftApp, ui: &mut Ui, current: &str) -> Option<String> {
|
||||
let mut chosen = None;
|
||||
for (name, _) in presets(app) {
|
||||
for (name, ..) in presets(app) {
|
||||
if ui.add(egui::Button::selectable(name == current, preset_label(&name))).clicked() {
|
||||
chosen = Some(name);
|
||||
}
|
||||
@@ -242,6 +243,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
retrace = true;
|
||||
}
|
||||
});
|
||||
// The preset's enhanced version (with Shapes and Auto Grouping) or its legacy one.
|
||||
if let Some((_, legacy, better)) = all.iter().find(|p| p.0 == st.preset && !p.2.is_null()) {
|
||||
let on = *better == st.params;
|
||||
if widgets::check(ui, tl!("Enhanced"), on, true) {
|
||||
st.params = if on { legacy.clone() } else { better.clone() };
|
||||
retrace = true;
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("View:"));
|
||||
if let Some(v) = view_dropdown(ui, st.view, 170.0) {
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`;
|
||||
this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in
|
||||
@@ -62,7 +62,9 @@ its target excludes the shared backend because Windows.Data.Pdf is unavailable t
|
||||
## Agent control
|
||||
|
||||
- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard
|
||||
injection and screenshots ([control-protocol.md](control-protocol.md)).
|
||||
injection and screenshots ([control-protocol.md](control-protocol.md)). Synthetic gestures are validated
|
||||
before queue mutation; the pending input queue is capped, and control requests yield after a bounded
|
||||
batch per frame (#1709).
|
||||
- **MCP:** `vectorcraft-cli mcp`, attached to a running app or headless ([mcp.md](mcp.md)).
|
||||
- **Sandboxing:** `--automation-read-root` / `--automation-write-root` confine what agents read and write.
|
||||
|
||||
@@ -72,10 +74,16 @@ No panics in shipped code (workspace clippy lints ban `unwrap`, `expect`, `panic
|
||||
`vectorcraft_engine::guard` catches a panic at every entry point and rolls the document back; Data Recovery
|
||||
saves copies in the background. See [development.md](development.md#robustness-vectorcraft-never-crashes).
|
||||
|
||||
## Corpus fetching
|
||||
|
||||
`cargo xtask corpus --download` retrieves only allowlisted regular PngSuite files over HTTPS at a pinned mirror commit. SHA-256 and exact size checks precede publication; the downloader never extracts an archive. Existing verified files are reused; failed downloads leave existing corpus files intact. See [development.md](development.md#verified-pngsuite-corpus).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. |
|
||||
| 2026-10-10 | minor | Added the `three-d` crate |
|
||||
| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Control protocol
|
||||
|
||||
> **Last updated:** 2026-10-13 · **Change:** bounded transport waits (#1726).
|
||||
> **Last updated:** 2026-10-13 · **Change:** synthetic input budgets (#1709); bounded transport waits (#1726).
|
||||
|
||||
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). The port must be a number
|
||||
from 1 to 65535 (`--control=<port>` works too), given once; otherwise the app prints an error and exits
|
||||
@@ -29,6 +29,20 @@ persistent clients can send further requests within these bounds, or reconnect a
|
||||
These transport budgets are separate from the existing 60-second command-processing wait.
|
||||
Transport: `apps/vectorcraft/src/control_server.rs`.
|
||||
|
||||
## Synthetic input budgets
|
||||
|
||||
`ui.drag` accepts at most **256** `steps`; `ui.click` and `ui.drag` accept at most **16** `count`.
|
||||
They must be non-negative JSON integers. Omitted values keep their defaults (8 steps, one click);
|
||||
explicit zero still acts as one, as before. Oversized or malformed values return an error before
|
||||
any move, press or release is queued.
|
||||
|
||||
All synthetic methods (`ui.key`, `ui.text`, `ui.move`, `ui.click`, `ui.drag`, `ui.wheel`) share a
|
||||
**1,024-event** pending-queue limit. The whole request must fit; rejection preserves existing input
|
||||
and does not append a partial gesture. Clients can retry once earlier input has drained. At most
|
||||
**64 control requests** run per frame; later requests remain on the channel and another frame is
|
||||
scheduled. These limits cover synthetic input admission, separately from socket transport and
|
||||
command-processing timeouts.
|
||||
|
||||
## Confining file access
|
||||
|
||||
```sh
|
||||
@@ -73,6 +87,8 @@ the two can still win that race.
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool` |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||
| `ui.move` | `{x, y}` | screen-point pointer move |
|
||||
| `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above |
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
||||
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
|
||||
@@ -802,6 +818,12 @@ resizes the root divider (`false`/`true` path entries select first/second childr
|
||||
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
|
||||
split paths return an error without changing the workspace.
|
||||
|
||||
## Synthetic input hardening checklist
|
||||
|
||||
- [x] Validate counts and reserve the whole gesture before queue mutation.
|
||||
- [x] Bound pending events across all synthetic methods, preserving rejected requests' prior queue.
|
||||
- [x] Yield after a finite control-request batch per frame; retain later requests and schedule another frame.
|
||||
|
||||
## Transport hardening checklist
|
||||
|
||||
- [x] Bound idle, partial-line and reply-write waits without renewing a partial-line deadline per byte.
|
||||
@@ -812,4 +834,5 @@ split paths return an error without changing the workspace.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). |
|
||||
| 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). |
|
||||
@@ -1,5 +1,7 @@
|
||||
# Development
|
||||
|
||||
> **Last updated:** 2026-10-13 · **Change:** verified PngSuite downloads (#1729).
|
||||
|
||||
## Web build
|
||||
|
||||
`apps/vectorcraft-web` runs the same `VectorcraftApp` in the browser through eframe's web runner. The renderer is wgpu: WebGPU where the browser has it, WebGL2 otherwise. It is Rust only; the only JavaScript is the glue wasm-bindgen generates.
|
||||
@@ -313,3 +315,31 @@ Vertical type keeps its logical order down the column. SVG export outlines text
|
||||
lines (with a warning) until it is written with `direction`/`unicode-bidi`; PDF and raster exports
|
||||
use the laid-out glyphs. No font is bundled for these scripts: the installed fonts' fallback
|
||||
covers them (the web build needs a font with that coverage).
|
||||
|
||||
## Verified PngSuite corpus
|
||||
|
||||
`cargo xtask corpus --download` fetches the 175 original PngSuite PNGs and their redistribution
|
||||
license/README from [the pinned lunapaint mirror](https://github.com/lunapaint/pngsuite/tree/8cd768dd0d0063195174d0d01cacbd5a7d1e5605).
|
||||
Its README identifies `png/` as the original 2017-07-19 files; converted BMP/JSON files are excluded.
|
||||
Each file's SHA-256 and exact byte length are committed in `xtask/pngsuite.sha256`.
|
||||
|
||||
Only HTTPS downloads and redirects are allowed. The manifest admits at most 256 flat filenames,
|
||||
1 MiB per file and 16 MiB total; current PNG data totals 114,649 bytes. Curl limits each download
|
||||
to its expected length, with a 10-second connect timeout and 60-second transfer timeout.
|
||||
Network fetching requires curl 8.4 or newer: earlier versions do not bound unknown-length
|
||||
responses ([curl documentation](https://curl.se/docs/manpage.html#--max-filesize)). Verified
|
||||
caches remain usable without invoking curl.
|
||||
Reads are independently bounded. Files are staged under temporary filenames, checked, then
|
||||
renamed into place; failed or substituted downloads never replace an existing oracle. The fetch
|
||||
stops on unexpected directories/symlinks or an existing staging file rather than overwriting them.
|
||||
Verified cached files need no network access. The corpus remains gitignored and optional.
|
||||
|
||||
The original site's plain HTTP archive is no longer fetched or extracted. To refresh the suite,
|
||||
review the mirror commit, original-file list, redistribution license, sizes and hashes together;
|
||||
do not replace expected hashes automatically in response to a download mismatch.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Document pinned PngSuite files, integrity checks and download budgets (#1729). |
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB nLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard andLine truncated
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
|
||||
@@ -82,10 +82,15 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
| Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) |
|
||||
| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art |
|
||||
|
||||
## Corpus download integrity
|
||||
|
||||
- [x] PngSuite opt-in downloads are pinned, SHA-256 verified and bounded before publication (#1729); no archive extraction. This secures fixture acquisition and does not raise format-fidelity scores.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); scores unchanged |
|
||||
| 2026-10-13 | minor | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); scores unchanged (estimated) |
|
||||
| 2026-10-13 | minor | 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); 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) (the `.ai` row for #1528 was written by that PR); scores unchanged (estimated) |
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaledLine truncated
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
@@ -100,6 +100,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
## G8 Hardening at scale
|
||||
|
||||
- **Done:** PngSuite fetching verifies 175 original PNGs and redistribution notices against a pinned SHA-256/size manifest before publishing; file and total-size budgets, cache verification and no archive extraction (#1729). The real-world corpus and manual QA gaps below remain.
|
||||
|
||||
- **Done:** bounded synthetic control-input expansion (#1709): click/drag counts and pending-event capacity are checked before mutation, and control-channel bursts yield between frames. The corpus and platform QA below remain pending.
|
||||
- **Done (2026-10-14):** WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The corpus and platform QA below remain pending.
|
||||
- **Done:** control sockets have bounded idle, absolute partial-line and reply-write waits (#1726); real TCP tests cover idle, trickled and blocked peers, buffered partial lines, persistent requests and connection-slot recovery. The corpus and platform QA below remain pending.
|
||||
- **Missing:** a corpus of real-world SVG/PDF/EPS files; more Affinity versions and platforms (51 pinned files
|
||||
@@ -258,6 +261,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | No entry closed. G11: Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). G8: WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). G10, unconfirmed: Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). A clause repeated in two G11 bullets is there once. Estimates unchanged |
|
||||
| 2026-10-13 | minor | No entry closed. Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); estimates unchanged |
|
||||
| 2026-10-13 | minor | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged |
|
||||
|
||||
+815
-513
File diff suppressed because it is too large.
Load diff
+8
-6
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
@@ -49,7 +49,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (bounded control socket waits and connection-slot recovery, #1726; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (closed SPLINE flags round-trip, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
@@ -60,12 +60,11 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); initial Revolve, Extrude & Bevel, Inflate and Rotate; SVG Filters, remaining raster effects and 3D materials, lights and shadows pending | 81–130 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ (new ruler-guide Alt orientation implemented; broader pass remains) | 60–90 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; broader pass remains) | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
|
||||
| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** |
|
||||
|
||||
Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area table in
|
||||
@@ -81,6 +80,9 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | M12 row: each documented Image Trace preset has an enhanced version (#1847) |
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775); Tests: text import fuzzing no longer searches installed fonts (#1785); Tests: render tests that add fonts no longer race the frame cache tests (#1795); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728); The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739); Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px); milestone rows and estimates unchanged |
|
||||
| 2026-10-13 | minor | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenTypeLine truncated
|
||||
| 2026-10-13 | minor | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: 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; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: 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; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site loLine truncated
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
@@ -92,7 +92,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) |
|
||||
| **Feature depth** | **105** | **~75%** | | **~250–420** | |
|
||||
| Interaction-fidelity pass (every tool's modifiers, cursors, Properties per context) | | | [ui-parity.md](ui-parity.md) | 60–90 | estimated |
|
||||
| Hardening (real-file corpus, cross-platform and browser QA, launch failures, performance budgets) | | | [gaps.md](gaps.md) | 50–80 | estimated |
|
||||
| Hardening (real-file corpus, cross-platform and browser QA, launch failures, performance budgets) | | | [gaps.md](gaps.md) | 50–80 | estimated; PngSuite fetch integrity and size budgets regression-tested (#1729) |
|
||||
| **Full parity** | | **~55%** | | **~360–590** | |
|
||||
|
||||
Excluding the generative-AI row (the old table had none), depth is ~76% and the remaining feature work
|
||||
@@ -330,7 +330,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | Bounded control socket waits (#1726); RPM runtime capabilities and glibc floor checked (#1275); real-distro installation/start-up QA pending | 50–80 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | Bounded control socket waits (#1726); PngSuite pinned-file integrity and download budgets verified (#1729); RPM runtime capabilities and glibc floor checked (#1275); real-distro installation/start-up QA pending | 50–80 |
|
||||
| **Full parity** | | **~56%** | | **~320–500** |
|
||||
|
||||
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
@@ -364,7 +364,7 @@ since 2026-10-01.
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; synthetic-input counts, pending events and per-frame control requests are bounded (#1709); what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
|
||||
## Evidence: what ships today, by area
|
||||
@@ -392,6 +392,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709).
|
||||
- The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
@@ -467,6 +468,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486).
|
||||
Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then.
|
||||
Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it.
|
||||
When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635).
|
||||
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged.
|
||||
@@ -483,7 +485,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
|
||||
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
|
||||
- **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, blurred, noisy and scanned gradients are found in part or in pieces, and radial gradients stay bands.
|
||||
- **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at Line truncated
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756).
|
||||
- **Paint and appearance (M3):**
|
||||
@@ -507,6 +509,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
|
||||
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). 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).
|
||||
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). 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). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
@@ -532,6 +535,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502).
|
||||
The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels.
|
||||
The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429).
|
||||
Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms.
|
||||
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
|
||||
An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429).
|
||||
When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns.
|
||||
@@ -544,6 +548,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
- **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames.
|
||||
|
||||
- **Type with transparency or effects:** type at less than 100% opacity, in a blend mode other than Normal, or with raster effects such as a drop shadow keeps its glyph outlines between frames, as opaque type does, and is laid out again when it is edited or the fonts change. At 2880 × 1800 on 8 threads, a page of 1,000 type objects takes 13 ms a frame instead of 23 ms at 80% opacity, 31 ms instead of 41 ms in Multiply and 16 ms instead of 26 ms with a drop shadow, with the same pixels.
|
||||
|
||||
- **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified.
|
||||
|
||||
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.
|
||||
@@ -553,10 +559,17 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Live objects after document changes:** a blend, envelope or repeat, with or without an effect of its own, is evaluated again when one of these changes: the tiling of a pattern whose tiles an envelope bends (Distort Pattern Fills), a symbol shown inside a symbol, a pattern or an opacity mask, the fonts of its type, a placed document's file once the file is read, and the color settings. A gradient mesh, with or without an effect of its own, is colored again when the color settings change.
|
||||
|
||||
- **Full font names (#1258):** OpenType name ID 4 identifies its face alongside family/legacy/PostScript names, including localized names, for loaded fonts, cataloged files and Missing Fonts searches. Synthetic name-table regressions cover full names distinct from their PostScript name, real-family precedence and variable fonts' default faces. Windows font-service activation remains unverified; scores and hours are unchanged.
|
||||
### Corpus integrity evidence
|
||||
|
||||
`xtask/src/pngsuite.rs` verifies each file from the pinned PngSuite mirror against `xtask/pngsuite.sha256` before it reaches the corpus. Seven synthetic regressions cover substitution, oversize, cache repair, failure cleanup, nonregular targets, stale staging and manifest limits. The opt-in corpus remains gitignored; no PNGs are committed.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: Shapes rework (#1845), square stroke caps (#1846), enhanced presets (#1847), gradients grown over noise and soft rims (#1848). Score and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Tools, UI and effects: Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); correction to the 2026-10-13 rows: a guide's position in the Transform panel (#1659) is not established by the documentation found (help pages read again on 2026-10-14) and is kept as a convenience pending the owner's decision, with no parity claim; scores unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Files, untrusted input and automation: EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775); Tests: text import fuzzing no longer searches installed fonts (#1785); Tests: render tests that add fonts no longer race the frame cache tests (#1795); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728); The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739); #1684 and #1797 wrote their own evidence; scores unchanged |
|
||||
| 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px); the Image Trace row keeps 75% (estimated): the score was raised for the measured work on 2026-10-13 and these two are refinements of it |
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# PngSuite original PNGs and redistribution notice
|
||||
# Source: https://github.com/lunapaint/pngsuite/tree/8cd768dd0d0063195174d0d01cacbd5a7d1e5605
|
||||
# sha256 bytes filename
|
||||
71e1f5bf865131b5d9996e76a78372146283e5089e50f900ae927a6458b62101 167 LICENSE
|
||||
a2853f2be68e460dcb9992819125b74503a01383e79f79a04b971ffa9d248544 716 PngSuite.README
|
||||
9bf1c4610280efecc291361e2b7b026d602d03a7137f476ff84af626bb7b3a62 217 basi0g01.png
|
||||
50ed4ff9c1c80578ae58ead6fb28ea2e44016b5b56a7b3e95c235ec86f97c0d8 154 basi0g02.png
|
||||
0cce4212157dae7c0f71f5f72cef39cecab3347de455da0ace7de83eebd47849 247 basi0g04.png
|
||||
b9300750e7414c9fed68cc7f4f531f5e2cdd2724f50142e8aa3a0070836bece9 254 basi0g08.png
|
||||
d354026bdd0aa6ed7bedea7c92e5eef2ebbd4854848b8a0a48f562cc71bfe80f 299 basi0g16.png
|
||||
b2690d4475cdc39faf5a7d2de20de4e14eb96c6360fa791ec2003b4085ecacde 315 basi2c08.png
|
||||
ae13eba5fabbf7cbe8c01ec4458cdaa7e6bfdfcbd5109e831efaac9f39096027 595 basi2c16.png
|
||||
8e1edfbc0382e0b545c70209271cedead2d530df1ce40f5296f0749226b36006 132 basi3p01.png
|
||||
85f304eaaf6831b2bcc2c08892b947fef52f06337aaf23312d6326e367d73f77 193 basi3p02.png
|
||||
78890ee2c4da71e460b5a1527efe7dc17d673ebe997f49ca1b4dcd7347d21e56 327 basi3p04.png
|
||||
ca103eef7a7c50abc7af9d5ec0dc9fdc07668e2f030adbce226e050dcec9eea3 1527 basi3p08.png
|
||||
dfae01a4548e30519157bb51ce29d4de3b35b74288e9568b4d5911de01c6048b 214 basi4a08.png
|
||||
ef7df23ccd912309a4f89ca3c3094bf5ab55add25218e571e51cc6cb11cfad9e 2855 basi4a16.png
|
||||
fe70ea20c8447e82abb7bc9dd05344a61a4631cc69e0974378df8f6263af0337 361 basi6a08.png
|
||||
7fdd6bf08f04692bcf06b5c4262e7f76c49d8adb992789fd3f05aa0435645cd8 4180 basi6a16.png
|
||||
c8b1364d7771dd2f5a1b2d7d633abcf3f48dafee608558ecd2e5fc98f61894cd 164 basn0g01.png
|
||||
ca172e92d179c77b3d321ad501e452a59bdaafafde8ebd74fd0669c4eae58efc 104 basn0g02.png
|
||||
fb2d42c4c8f5c51590e675994a9b0c718fbf71c80e28142f231cfa7c5b0e26e8 145 basn0g04.png
|
||||
268d061075d1dd2eeec62b31303d09f6998549e1bfb447a5f09c80a2b0978ac3 138 basn0g08.png
|
||||
1a1fe155f40c11d79350b3f84c7eb2b29719037e57073cb7ae2ab9e8323f9360 167 basn0g16.png
|
||||
c90e86090a625661b19960cafdde6e347d6e32d73837aaae533f66dd3f099506 145 basn2c08.png
|
||||
1e1c28ec0ee5da6224404b4005076dd30e2335a1a80c0ca722046a245c4a2b3b 302 basn2c16.png
|
||||
c2d7cd682df5f74506b33a5d70c344aaee248fda79fdfef8e873426fd6f2b75b 112 basn3p01.png
|
||||
0466bb7ed9984cf03b70704564bcffab1df8ec0e8167473ba0f75e4fedce5a8f 146 basn3p02.png
|
||||
e1fc7be978d3149b98533d0076245ae64353b7967290f4204c1282ecb4ec1aba 216 basn3p04.png
|
||||
d58256cd2eb16b5740d4c1403d25ce43d8dd03e270627ab709d2fb141e3d904c 1286 basn3p08.png
|
||||
7be24b618bfd437b921ae0caf9341d112338c6290cc62466deb19d2c7a512968 126 basn4a08.png
|
||||
1c92ffd11d2fc89a36f170d0239668436407f5c8e3f8d93483fb3fc6bca361d7 2206 basn4a16.png
|
||||
559c594166eb156f461c9beff0f053196730dc998fdb0d2b801c89e6680860a5 184 basn6a08.png
|
||||
8f9d81060aebf4576461403c5057de7f23f73157016b659402b906df805845aa 3435 basn6a16.png
|
||||
dfae01a4548e30519157bb51ce29d4de3b35b74288e9568b4d5911de01c6048b 214 bgai4a08.png
|
||||
ef7df23ccd912309a4f89ca3c3094bf5ab55add25218e571e51cc6cb11cfad9e 2855 bgai4a16.png
|
||||
559c594166eb156f461c9beff0f053196730dc998fdb0d2b801c89e6680860a5 184 bgan6a08.png
|
||||
8f9d81060aebf4576461403c5057de7f23f73157016b659402b906df805845aa 3435 bgan6a16.png
|
||||
71d041df5c8949ce07d5e93b3c9f752cefc75ef7326c49df05ae73915f1a755f 140 bgbn4a08.png
|
||||
c45de44d3442bd33072d68456541b10e0cc57c5d8bfc809208995d939091eb2d 2220 bggn4a16.png
|
||||
2da3c3efa01bf166958852a104ece92daaede8ffb5e0452a55d40526258a8bf5 202 bgwn6a08.png
|
||||
875bc2b44a4355551c51260def9664da68be1af8c14b82f76173aaf01ddb1929 3453 bgyn6a16.png
|
||||
c88909e74e039dd3df829bf24144b487171e53b17f5c5c07cbc08688247d24b5 1514 ccwn2c08.png
|
||||
dc365b39d49c287669d837872dd59aef30763a611dfc8c046c481e35e49a390a 1554 ccwn3p08.png
|
||||
438018f19c85e582cb586ac7cca2220008ecb7fc70ce50e8f2a76b494c128a20 404 cdfn2c08.png
|
||||
a068eaf1f7040490e08eda3259befb6689849dd0ff8bb4cc03c705d117cb2b9f 344 cdhn2c08.png
|
||||
ee08dfa303a3c09171280fc1821180dc0a4c3591af7e9077e8ce0435642c76d2 232 cdsn2c08.png
|
||||
a849d89ba3d92146940d7c56d2fe74c9c3ec96725c7f24eefaf44160a6304de2 724 cdun2c08.png
|
||||
587a8624228d3f605f475bd751a1f3a97f0bbab590c0ff518bce5da3abcb6f2b 258 ch1n3p04.png
|
||||
e3cace549047c6e5e693b9b135d53f7f9ca5b1088bf30cc408634598ba53758a 1810 ch2n3p08.png
|
||||
79ccaf4abe5091e1f65f8a61496363ed3312ebb9ea1aaa98affadac3d98507de 292 cm0n0g04.png
|
||||
127bb50d224ac3ebff36160482a03b9e6686072e86c8ff4cd43e3ce2271b2315 292 cm7n0g04.png
|
||||
774b384d0ad73698e7d93fc5a29976b332c619045e5966276c609b11459a4152 292 cm9n0g04.png
|
||||
bc812de2b83abc9ae2a6d1ae01e91f9090214b9ed8fee1d56cdfea58f4f259d0 214 cs3n2c16.png
|
||||
267104dced0858dd828cdd41edc0e87042c7094c77191565192a67045064b23f 259 cs3n3p08.png
|
||||
028fa49fbba3954c2eba27a4c3607196e87c1dae74192df3b3ad7afe5e0a3f0c 186 cs5n2c08.png
|
||||
a7a4021983d6b22685529cbafb65cd087c9e1af1c5d205c2e13e3efc4ae87b6a 271 cs5n3p08.png
|
||||
d90ddab7313e4e1fd7f20f16a6c546b449697fbcac1a7776298a74896f4662da 149 cs8n2c08.png
|
||||
5bffdb88e307a851f8e85b137a69c59e557a881db816e990061934c0155c894c 256 cs8n3p08.png
|
||||
081d1ec26b4157fbc032b76dc716321420f2d032a425de046557c7842766826d 273 ct0n0g04.png
|
||||
259116f8ecf849d83d824688eb02b4575aed11980f02ab6fb123281c25a6459d 792 ct1n0g04.png
|
||||
4583e63d1bdfa18b6abb47439dee9a73bed311ed1b0a71d22bed75fe4767832b 742 cten0g04.png
|
||||
d0607280c3539a8934c5cfd22788b382cdc31321cdedc75ae8a29e3948580ba6 716 ctfn0g04.png
|
||||
5f4c87f5a8f589d029a908050880ac5d6aed3810e55e75444802d6d3fb27cf33 1182 ctgn0g04.png
|
||||
8ef799e59871755578e563c5b411bc3895c2f7112c115aaa921b6c04fe255bf2 1269 cthn0g04.png
|
||||
ca91332ecc04e5faac1f39ca4df4996b072a93f3e574e7f4b50d52aafbfe67b6 941 ctjn0g04.png
|
||||
c0765e635a6423ec64f0314400c3df20745c4a4469ffcc323c7b00ba66b4aceb 753 ctzn0g04.png
|
||||
d04140d74bc60597c47b5aac371a3553d3add9354437bbd8c57007d94e197809 1788 exif2c08.png
|
||||
d055cc0bb505b37c6ecf88a808d71675eca5dd0ee683d70ebc9c0be0e1bb8e57 319 f00n0g08.png
|
||||
0fe92b2aa2da04c885d1dbd85c834716f6cdd946364d97dcd597bb79d9e14427 2475 f00n2c08.png
|
||||
956ddaf133e6d9a8d36f1e00604d87fbc2d0295e933bb8caaef517e7d87e5342 321 f01n0g08.png
|
||||
ef072ec6815ebf9b33e0553d2e4e4e7ed6911860a2512c67bcd10a9f0f09b9de 1180 f01n2c08.png
|
||||
1f5b49c06ecc2a1756f0423b3de936bd18497866d035ac8eded78a6f17408a2c 355 f02n0g08.png
|
||||
ca4b937b3c587d5c007f193a2eec14dc96b0d23ff7d6aa9004e3badd1af9fe8f 1729 f02n2c08.png
|
||||
d0563ae30c6ce7a01eae7a5cdc0ef780175bfa7653888ed93f17bef5fe4b2107 389 f03n0g08.png
|
||||
2d101e3ef4f78a69437034671e93fe11faac0cfc4d44210dcca1b944caa886f7 1291 f03n2c08.png
|
||||
fec1a3c53833d0ddefa4486a98934c393cb4335b2dc31da70e6540a4686f1457 269 f04n0g08.png
|
||||
c365c24153cb69fd3c162f00b296ae23a71a1595645d1aeb0ad23af680d7b4be 985 f04n2c08.png
|
||||
8ac0f095d2a943157e820fa121bccde08d5230af1b5830c3041d5f4da3524eba 426 f99n0g04.png
|
||||
3494b914dd1b094afa9a74a89bfa75219030e21af6fca3ed66c9d45032a047e9 345 g03n0g16.png
|
||||
abce774b9624952c2d0c57ff681790ba620cf5616fe7e0ef0600600cdce3c22a 370 g03n2c08.png
|
||||
541389db0c72721c6c49501e1dbf6e32df41b4fcfa318676955e278f3f4332b3 214 g03n3p04.png
|
||||
72350a2d9db2df09b626f1f1dd48061e9c749d471bf19814d2c5c10acbf007e5 363 g04n0g16.png
|
||||
bfd3edfc4d85a43383556a51023533fa3888184aa53a6b43769910782beebfce 377 g04n2c08.png
|
||||
5ea85ebefb58b2ab113fc05bf1cb79ffb068359e3a5620843590a0a7b59bd49a 219 g04n3p04.png
|
||||
be19721d28e0b268af1e29e8190c952b94315bd08899383ae2c91e6dc8c4f889 339 g05n0g16.png
|
||||
74ab5b8477992d65e220eeaff8df466522c1b392fa90c6f9935da5bdb7113c9d 350 g05n2c08.png
|
||||
2f46afd7ec15836b523cbce6965f34d89833b8d777afffaf0f53bce620d5f65e 206 g05n3p04.png
|
||||
9070f843981f765cc8e26a63751be5d58631d85a8a7b4e826358f80e8a679d03 321 g07n0g16.png
|
||||
ca4cd32d222f65fb7beb7c9f40d8b6833b36d552b830ff1a1d67dae73b8ed0d8 340 g07n2c08.png
|
||||
b8e7abeabcfd50b71cca78b6cdff7372310cfc179cd63304c3e904f469b333cb 207 g07n3p04.png
|
||||
a22486acb74ee5f947db3a20b1f314a9dd8aca3339840049ee559f4d676a6b5c 262 g10n0g16.png
|
||||
4ac23729aea109e8f6b1e831448115cf02dcd97559e9403847cbe6dd7e5c7347 285 g10n2c08.png
|
||||
36cc2ea4b5b33cd18e0a01c7de85dbcaf7161d258cd0f2265a90cd835b15cf6d 214 g10n3p04.png
|
||||
1fae707d809296d2cf0d6aa653bead6199644fb1c859558ec7d2d69e709a423a 383 g25n0g16.png
|
||||
9b128cfa1bb417dd99251914d729062621fcea9f346168549b16a75fe030b0f6 405 g25n2c08.png
|
||||
4197a0a25c4f74d42c2285d75b9fabe1eedbed45bc224ff1ec1784f5797e9dbf 215 g25n3p04.png
|
||||
1a1fe155f40c11d79350b3f84c7eb2b29719037e57073cb7ae2ab9e8323f9360 167 oi1n0g16.png
|
||||
1e1c28ec0ee5da6224404b4005076dd30e2335a1a80c0ca722046a245c4a2b3b 302 oi1n2c16.png
|
||||
142bc4b1e9f8a9e3b0ba3d2c2025e485bf7e76df88f914bf8c9041b4562cc080 179 oi2n0g16.png
|
||||
2c523f138e0ade1bed8f31d59ec0f34b82660b192d6af8b8f395f82b9ba9dabb 314 oi2n2c16.png
|
||||
aa2d77723ba44f654a41a2ab97e01271fe942fecd2f299c0a81da90acb2887ac 203 oi4n0g16.png
|
||||
661220a0ed03ca6d63ec8dcaac738d4a9fd089b491c40dd896267ffb6cf5015d 338 oi4n2c16.png
|
||||
ae70c9effd0d9957a0d42771bc987529a1f3715e068057fae63f0c7e50d5c53a 1283 oi9n0g16.png
|
||||
fef98e9bebe6c4046c29f2568f7a735305c8661aa01f9c37a718135e5bc4f941 3038 oi9n2c16.png
|
||||
72406b6173ec20dd059e8cea594ef5bc8f81bac5e416e2b53058d2debd51d195 962 pp0n2c16.png
|
||||
d5a6f964a601a5e1815525117c12c32b61e2aa09fd6040776d35f2f7115b7110 818 pp0n6a08.png
|
||||
b321b9ac8f2aa641475b6936365954181726dcb8662dbf641284d82a83cc22a5 1456 ps1n0g08.png
|
||||
6c59d673032fe2d04dbd2ea91cc9bc7bebe7d5ab84911f00e36fb28994793576 1620 ps1n2c16.png
|
||||
bd2ae1b45fd771aaa8c57dccd183f3834fc9742f6dfc692606865049efb116e5 2320 ps2n0g08.png
|
||||
120c62ce9d014f7772c4b38a446991b5bbe6a22ef5ea59239d32e023bc167585 2484 ps2n2c16.png
|
||||
235eb82f1337e3fcefbdca566a53af3d3523db7588ba3cd7fa5dab19afe48a41 113 s01i3p01.png
|
||||
6a4f6da9fe36d195219ad2beeac1a0423e2ee6de03dfb064a3b6fa31623eb230 113 s01n3p01.png
|
||||
e7d26ab4b31e7066b0674a4374c093967a9f1761b96836aa3dbe624acd73ab52 114 s02i3p01.png
|
||||
f4b324f2b1d013ee77f632e42129c7cd3eca55da846e775e744a1f224ad1efcd 115 s02n3p01.png
|
||||
4513dec80f54ffd04a28834b114254d6e6d66e52e32e0059174474f2fb15d6bc 118 s03i3p01.png
|
||||
4dee297bd7f747f87488509af11a1e1e0531fa67e6e8d0f8ab7f4df7940f0ff3 120 s03n3p01.png
|
||||
918f94f3557e10de91efa680df507c8eafc347fa5023af43037701398128ad0f 126 s04i3p01.png
|
||||
e73c52f764b684ec5133cce11f7f18cc60065acdd308e2c3d871e0a792a1080e 121 s04n3p01.png
|
||||
77fe040e17167f9656afe46cf0031832e635745e4e7f19ba12dee32f5ec03d96 134 s05i3p02.png
|
||||
1b9e244a5c1d6cc9830391fff4eac565ada026d000e60faf7525c8add6bdc726 129 s05n3p02.png
|
||||
cf8d5fdb34c0afd38b1b4745bed6167771767ccdfe847c4d8766f7fcc5c41176 143 s06i3p02.png
|
||||
5caabfc4b147fad5e49e558143faaab91888324240acf45e5db8fc49f7a7f667 131 s06n3p02.png
|
||||
d9874bfad10353a9ea2876ee388c3e7f81f697ed141d0d79c5c2019d62a7a698 149 s07i3p02.png
|
||||
e7d447394f5fa64c27353216af387352a77a1fb534922a2d974e60d965d4e414 138 s07n3p02.png
|
||||
62adf855c6b1e3a007f9dcf3ce2ba244616c089269a511dd939e89a4b0853453 149 s08i3p02.png
|
||||
544fdd476e566e8aef4c98604124ae05acef48be20d1aebb9b6674b08b2abbb6 139 s08n3p02.png
|
||||
f2cf5b3109690704d106a0b22ea079e36064cc6077e1fb9a1ba87397d0bbcca1 147 s09i3p02.png
|
||||
6be8f58b638e7f7114750f5796f66f650db8fb7242a36183850f02117f4eaa65 143 s09n3p02.png
|
||||
a27e0dbf923ef868e7399560b76c23a50b2d8926cddd9775e2bc4e050273f205 355 s32i3p04.png
|
||||
c80d9fc7ded0b7834ec11d0e324540c7be74016b61bc302a1d167218fa35fbf9 263 s32n3p04.png
|
||||
cc6ad98dcf686d92f1165d951f90fac70e568cedb1fcbba33e8890d0e6cdad4d 385 s33i3p04.png
|
||||
998d08e084e3eea288984475bfed0d48ee2d8250e20b96755898b59152e36a34 329 s33n3p04.png
|
||||
83eb2e82eb14ae3c608833ef371a6f472c4c6e63b7e9f4f92f5472f975793b97 349 s34i3p04.png
|
||||
a25451168c0b03ab8f52dcc60c6b9c12bc5df5d55afabe1892b87d0a856bb4a0 248 s34n3p04.png
|
||||
db5988c27ad577de1e494cb50229dc2772678531adde2db84493907eae003e54 399 s35i3p04.png
|
||||
cd99fc4cc69bda1ff8e8697d8b423ae8d9e84a58ba1d2b480751953b6869e3b3 338 s35n3p04.png
|
||||
a36b40927b730a31d60c7818fb4a32e67ace06393cdaa26edc09520b6cee066d 356 s36i3p04.png
|
||||
ebc08f6a1e0918c5d3a9a08e184c23019a3cf90d8843e4b71bf2ae16040b1a84 258 s36n3p04.png
|
||||
565c79404c4b9e46c27edb2f93d7b26b93ec0a0481ab7a21fc405152a21f53ba 393 s37i3p04.png
|
||||
964078b1f3f472268b8ac97a8976b4d1cdb6a47e6243d67eddcb0205e65b9f61 336 s37n3p04.png
|
||||
b107f120ffaffcb949ac4547e0f91555ac681f78c7e68c724e7ceddfd8e5387b 357 s38i3p04.png
|
||||
045b0f5ea35874741bf6cac40f2e63ec8726450ab241475df4b7e9303c39e339 245 s38n3p04.png
|
||||
4b5a6d8c04c8c70e1de3bcb6f7b5cc3ba359b13e28de6f52e21a291d4bc8a937 420 s39i3p04.png
|
||||
2115b6cad0140ea97fe6ec056b47307ef1e8b5c00db5f08707a0572b165e5f08 352 s39n3p04.png
|
||||
48a3f19b5c3b2f7eca17fd34765a2a3e385d770f24c357bc66bd74417eb60a5a 357 s40i3p04.png
|
||||
c4598ea73b1ec015bef7dcfd7f67bc7bfc41546619953d1a92cd9b8657a7033a 256 s40n3p04.png
|
||||
c5e7eeaffa677bfac002ee968a0423e0fea3be7701d39ccd6a015df1d2d85e40 429 tbbn0g04.png
|
||||
8fcfd031a5b716201854fdab25691feed6cd3ed7b713a51b94e1f43fc63fe0c1 2041 tbbn2c16.png
|
||||
8eb43a6020fda8ed7c8c9b88b974ef794ee8452db29212ae7e5c11003bfd76a3 1499 tbbn3p08.png
|
||||
49711ee271f96099521b83b75d58ed2f76d42ed46cb3c06656fde2cad697cf59 2041 tbgn2c16.png
|
||||
6f798628935a021fe446dce3d62861d1f7369aaf01bad10daa4917c5abccad7d 1499 tbgn3p08.png
|
||||
8223d8b0c5f84b149f0c1f0e1f658346e07fcbd03ffc02b2e9eeea1481eb19bf 1633 tbrn2c08.png
|
||||
01e86f108b244bdee4d72e384aced8a62369965ca2993ee6ef88f131c647f7d1 1313 tbwn0g16.png
|
||||
978b28cec43bd219517083b63661bb30a7b9cdf9fd4476ab4e70844565980d51 1496 tbwn3p08.png
|
||||
7b4eb64b4bd7eb36417752873ddaca027ce77a07f474b2af569f4eea1e984111 1499 tbyn3p08.png
|
||||
f05e317f64a038123faf96fdbd6fc3ab3eec14e1fc1eb87fd7da4d996be63526 116 tm3n3p02.png
|
||||
a253e055d8cd94049cd95e9392e7aa60f3d5c922a8f0cccb84762b0c5f547545 719 tp0n0g08.png
|
||||
5dced3fde56b950e61ce86f4ad54458336f66389ce8ca68524d4fd8ec546ff5b 1594 tp0n2c08.png
|
||||
da40ddb18bd45db32ed03dc89e2250b096d808efe975a88477e4f5a91a4677c9 1476 tp0n3p08.png
|
||||
50bc927fe6cf1816454a7c27f8c743734be8db45e37e5ee3ede540f4e895e6c5 1483 tp1n3p08.png
|
||||
4059f7e6a1c5bac1801f70e09f9ec1e1297dcdce34055c13ab2703d6d9613c7e 138 xc1n0g08.png
|
||||
e252a0e7df3e794e52ce4a831edafef76e7043d0d8d84019db0f7fd0b30e20f4 145 xc9n2c08.png
|
||||
3c0c2a68dd416a7be79400e0e2ceb358ba3191d5309d4bad9dd04b32ff6d5b60 145 xcrn0g04.png
|
||||
71e4b2826f61556eda39f3a93c8769b14d3ac90f135177b9373061199dbef39a 164 xcsn0g01.png
|
||||
c1287690808e809dc5d4fb89d8a7fd69ed93521f290abd42021ca00a061a1ba4 145 xd0n2c08.png
|
||||
00b53c3bbd0641454521b982bc6f6bcfda7c91f1874cefb3a9bac37d80a1a269 145 xd3n2c08.png
|
||||
16e5b40fb2600db1af20fb79ff715c2869255e2d4bef20702f16534e5dd6a847 145 xd9n2c08.png
|
||||
f9d1fb2a708703518368c392c74765a6e3e5b49dbb9717df3974452291032df9 61 xdtn0g01.png
|
||||
318864720c8fc0dbe4884035f2183cc3bd3d92ec60d447879982942600e9fe2e 138 xhdn0g08.png
|
||||
968fd21abb8acf40fbca90676de3abcb3a6e35b01ba7a3b9190184eafb99c83d 145 xlfn0g04.png
|
||||
776701227c7094dd10b78f508430b8ea4f03471d072096382cbcca628dea1d2b 164 xs1n0g01.png
|
||||
9dd7e93ba9211f0caee09d8f12e37742936c469f45fcad21e577c9f0640cf99e 164 xs2n0g01.png
|
||||
166c8633d116d1c26631cb98f3a75340cf4385fa7ae6a67913bc78e709bcb30d 164 xs4n0g01.png
|
||||
7a380568beeac969908196ac89cd2347c96ac2f26fdc2b8e0314b62c59f0e308 164 xs7n0g01.png
|
||||
e4767d3259e1b331c9ece9fb07fc30f385879ac12b3cd0e1cb923f3bfac81dcf 3172 z00n2c08.png
|
||||
939abfc6e0a3e349bb5bcd8c4e427a6cdb2c7e0c23a773aefa1c4fc9f07e4cb4 232 z03n2c08.png
|
||||
21652fe42f472acd83dcb207806add5737db0020b21d6b38b0a00be3e9e37990 224 z06n2c08.png
|
||||
98d90a7e61aa1a9ac9d99af44dd25c70cd5a7b4a11a85da60f82923e18a78f9f 224 z09n2c08.png
|
||||
+4
-17
@@ -9,6 +9,7 @@ mod brands;
|
||||
mod bundle;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod pngsuite;
|
||||
mod sha256;
|
||||
mod stats;
|
||||
mod version;
|
||||
@@ -224,16 +225,14 @@ fn cmd_ci() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||||
|
||||
fn cmd_corpus(download: bool) -> Result<(), String> {
|
||||
let corpus = root().join("corpus");
|
||||
println!(
|
||||
"Test corpora live under {} (git-ignored, never committed).
|
||||
Tests that use a corpus skip cleanly when it is absent.
|
||||
|
||||
corpus/pngsuite/ PngSuite (public domain) — vectorcraft-codecs compares every file
|
||||
against the `image` crate. Fetch: cargo xtask corpus --download
|
||||
corpus/pngsuite/ Original PngSuite PNGs (redistribution permitted), pinned and SHA-256
|
||||
verified. Fetch: cargo xtask corpus --download
|
||||
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
|
||||
Copy files in manually; licences must be MIT/BSD/CC0.
|
||||
corpus/tiff/ libtiff pics (optional)
|
||||
@@ -247,17 +246,5 @@ Tests that use a corpus skip cleanly when it is absent.
|
||||
if !download {
|
||||
return Ok(());
|
||||
}
|
||||
let dest = corpus.join("pngsuite");
|
||||
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||
let tgz = corpus.join("PngSuite-2017jul19.tgz");
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args(["-fsSL", "-o"]).arg(&tgz).arg(PNGSUITE_URL);
|
||||
run(curl, &format!("curl {PNGSUITE_URL}"))?;
|
||||
let mut tar = Command::new("tar");
|
||||
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
|
||||
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
|
||||
let _ = std::fs::remove_file(&tgz);
|
||||
let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0);
|
||||
println!("PngSuite: {n} PNG files in {}", dest.display());
|
||||
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
|
||||
pngsuite::fetch()
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Pinned PngSuite files: verify bounded HTTPS downloads before publishing, without extracting archives.
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{root, run, sha256};
|
||||
|
||||
const SOURCE: &str = "https://raw.githubusercontent.com/lunapaint/pngsuite/8cd768dd0d0063195174d0d01cacbd5a7d1e5605";
|
||||
const MANIFEST: &str = include_str!("../pngsuite.sha256");
|
||||
const MAX_FILE: u64 = 1024 * 1024;
|
||||
const MAX_TOTAL: u64 = 16 * MAX_FILE;
|
||||
const MAX_FILES: usize = 256;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entry<'a> {
|
||||
sha: &'a str,
|
||||
bytes: u64,
|
||||
name: &'a str,
|
||||
}
|
||||
|
||||
fn entries(manifest: &str) -> Result<Vec<Entry<'_>>, String> {
|
||||
let mut result = Vec::new();
|
||||
let mut names = HashSet::new();
|
||||
let mut total = 0u64;
|
||||
for line in manifest.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')) {
|
||||
let fields: Vec<_> = line.split_whitespace().collect();
|
||||
let [sha, size, name] = fields.as_slice() else { return Err("invalid PngSuite manifest row".into()) };
|
||||
let png = name.strip_suffix(".png").is_some_and(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()));
|
||||
if !png && !matches!(*name, "LICENSE" | "PngSuite.README") {
|
||||
return Err(format!("unexpected PngSuite filename: {name}"));
|
||||
}
|
||||
if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
|
||||
return Err(format!("invalid PngSuite sha256: {name}"));
|
||||
}
|
||||
let bytes = size.parse::<u64>().map_err(|_| format!("invalid PngSuite size: {name}"))?;
|
||||
if bytes == 0 || bytes > MAX_FILE || bytes > MAX_TOTAL.saturating_sub(total) || result.len() >= MAX_FILES || !names.insert(*name) {
|
||||
return Err(format!("PngSuite manifest budget or duplicate filename: {name}"));
|
||||
}
|
||||
total += bytes;
|
||||
result.push(Entry { sha, bytes, name });
|
||||
}
|
||||
if !result.iter().any(|e| e.name.ends_with(".png")) {
|
||||
return Err("PngSuite manifest has no PNG files".into());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn verified(path: &Path, entry: &Entry<'_>) -> Result<bool, String> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(e) => return Err(format!("{}: {e}", path.display())),
|
||||
};
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(format!("{}: expected a regular corpus file", path.display()));
|
||||
}
|
||||
if metadata.len() != entry.bytes {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
File::open(path).and_then(|f| f.take(entry.bytes + 1).read_to_end(&mut data)).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(data.len() as u64 == entry.bytes && sha256::hex(&data) == entry.sha)
|
||||
}
|
||||
|
||||
struct Download(PathBuf);
|
||||
impl Drop for Download {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort removal of our disposable download on success, rejection or network failure.
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_into(dest: &Path, manifest: &str, mut download: impl FnMut(&str, &Path, u64) -> Result<(), String>) -> Result<usize, String> {
|
||||
let entries = entries(manifest)?;
|
||||
fs::create_dir_all(dest).map_err(|e| format!("{}: {e}", dest.display()))?;
|
||||
if !fs::symlink_metadata(dest).map_err(|e| e.to_string())?.file_type().is_dir() {
|
||||
return Err("PngSuite destination must be a regular directory".into());
|
||||
}
|
||||
let mut count = 0;
|
||||
for entry in entries {
|
||||
let out = dest.join(entry.name);
|
||||
if !verified(&out, &entry)? {
|
||||
let temporary = dest.join(format!(".{}-{}.download", entry.name, std::process::id()));
|
||||
// Reserve a new regular file, never reuse somebody else's stale download or link.
|
||||
OpenOptions::new().write(true).create_new(true).open(&temporary).map_err(|e| format!("{}: {e}", temporary.display()))?;
|
||||
let temporary = Download(temporary);
|
||||
let folder = if entry.name.ends_with(".png") { "/png" } else { "" };
|
||||
download(&format!("{SOURCE}{folder}/{}", entry.name), &temporary.0, entry.bytes)?;
|
||||
if !verified(&temporary.0, &entry)? {
|
||||
return Err(format!("{}: size or sha256 does not match xtask/pngsuite.sha256", entry.name));
|
||||
}
|
||||
fs::rename(&temporary.0, &out).map_err(|e| format!("publish {}: {e}", out.display()))?;
|
||||
}
|
||||
count += usize::from(entry.name.ends_with(".png"));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn supported_curl(version: &str) -> bool {
|
||||
let Some(version) = version.strip_prefix("curl ").and_then(|s| s.split_whitespace().next()) else { return false };
|
||||
let mut parts = version.split('.');
|
||||
let major = parts.next().and_then(|s| s.parse::<u64>().ok());
|
||||
let minor = parts.next().and_then(|s| s.parse::<u64>().ok());
|
||||
matches!((major, minor), (Some(major), Some(minor)) if (major, minor) >= (8, 4))
|
||||
}
|
||||
|
||||
pub fn fetch() -> Result<(), String> {
|
||||
let dest = root().join("corpus/pngsuite");
|
||||
let mut checked_curl = false;
|
||||
let count = fetch_into(&dest, MANIFEST, |url, path, bytes| {
|
||||
if !checked_curl {
|
||||
// Older curl only bounds responses whose length is known before downloading.
|
||||
let version = Command::new("curl").arg("--version").output().map_err(|e| format!("curl --version: {e}"))?;
|
||||
if !version.status.success() || !supported_curl(&String::from_utf8_lossy(&version.stdout)) {
|
||||
return Err("PngSuite downloading requires curl 8.4 or newer to bound unknown-length responses".into());
|
||||
}
|
||||
checked_curl = true;
|
||||
}
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args([
|
||||
"-fsSL",
|
||||
"--proto",
|
||||
"=https",
|
||||
"--proto-redir",
|
||||
"=https",
|
||||
"--connect-timeout",
|
||||
"10",
|
||||
"--max-time",
|
||||
"60",
|
||||
"--retry",
|
||||
"2",
|
||||
"--max-filesize",
|
||||
])
|
||||
.arg(bytes.to_string())
|
||||
.arg("-o")
|
||||
.arg(path)
|
||||
.arg(url);
|
||||
run(curl, &format!("curl {url}"))
|
||||
})?;
|
||||
println!("PngSuite: {count} PNG files verified in {}", dest.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
struct Temp(PathBuf);
|
||||
impl Temp {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("vectorcraft-pngsuite-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Temp {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn manifest(data: &[u8]) -> String {
|
||||
format!("{} {} test.png\n", sha256::hex(data), data.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substituted_and_oversized_downloads_never_replace_corpus_files() {
|
||||
let temp = Temp::new();
|
||||
let out = temp.0.join("test.png");
|
||||
let good = b"original PNG oracle";
|
||||
for replacement in [b"substitutePNGoracle".as_slice(), &[0; 100]] {
|
||||
fs::write(&out, b"old corpus data").unwrap();
|
||||
let error = fetch_into(&temp.0, &manifest(good), |_, path, limit| {
|
||||
assert_eq!(limit, good.len() as u64);
|
||||
fs::write(path, replacement).unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.contains("size or sha256"));
|
||||
assert_eq!(fs::read(&out).unwrap(), b"old corpus data");
|
||||
assert_eq!(fs::read_dir(&temp.0).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_cache_is_reused_and_corrupt_cache_is_repaired() {
|
||||
let temp = Temp::new();
|
||||
let data = b"verified oracle";
|
||||
let m = manifest(data);
|
||||
let mut requests = 0;
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
fetch_into(&temp.0, &m, |url, path, _| {
|
||||
requests += 1;
|
||||
assert!(url.starts_with(SOURCE) && url.ends_with("/png/test.png"));
|
||||
fs::write(path, data).unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(requests, 1);
|
||||
fs::write(temp.0.join("test.png"), b"wrong oracle!!!").unwrap();
|
||||
fetch_into(&temp.0, &m, |_, path, _| {
|
||||
fs::write(path, data).unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(temp.0.join("test.png")).unwrap(), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_failures_clean_staging_and_nonregular_targets_are_rejected() {
|
||||
let temp = Temp::new();
|
||||
let m = manifest(b"png");
|
||||
assert!(
|
||||
fetch_into(&temp.0, &m, |_, path, _| {
|
||||
fs::write(path, b"partial").unwrap();
|
||||
Err("network failed".into())
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fs::read_dir(&temp.0).unwrap().count(), 0);
|
||||
fs::create_dir(temp.0.join("test.png")).unwrap();
|
||||
let e = fetch_into(&temp.0, &m, |_, _, _| panic!("must not download over a nonregular file")).unwrap_err();
|
||||
assert!(e.contains("regular corpus file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_download_is_never_overwritten() {
|
||||
let temp = Temp::new();
|
||||
let stale = temp.0.join(format!(".test.png-{}.download", std::process::id()));
|
||||
fs::write(&stale, b"keep this earlier download").unwrap();
|
||||
assert!(fetch_into(&temp.0, &manifest(b"png"), |_, _, _| panic!("must not reuse an existing staging file")).is_err());
|
||||
assert_eq!(fs::read(stale).unwrap(), b"keep this earlier download");
|
||||
assert!(!temp.0.join("test.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_unexpected_names_duplicates_and_resource_budgets() {
|
||||
let sha = sha256::hex(b"png");
|
||||
for name in ["../test.png", "a/b.png", "a\\b.png", "a:stream.png", "script.exe", "a..png"] {
|
||||
assert!(entries(&format!("{sha} 3 {name}\n")).is_err());
|
||||
}
|
||||
for m in [format!("{sha} 0 a.png"), format!("{sha} {} a.png", MAX_FILE + 1), format!("{sha} 3 a.png\n{sha} 3 a.png"), "bad 3 a.png".into()] {
|
||||
assert!(entries(&m).is_err());
|
||||
}
|
||||
let m: String = (0..17).map(|i| format!("{sha} {MAX_FILE} a{i}.png\n")).collect();
|
||||
assert!(entries(&m).is_err());
|
||||
let m: String = (0..257).map(|i| format!("{sha} 1 a{i}.png\n")).collect();
|
||||
assert!(entries(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_manifest_contains_the_original_175_pngs_and_redistribution_notice() {
|
||||
let e = entries(MANIFEST).unwrap();
|
||||
assert_eq!(e.iter().filter(|e| e.name.ends_with(".png")).count(), 175);
|
||||
assert!(e.iter().any(|e| e.name == "LICENSE"));
|
||||
assert!(e.iter().any(|e| e.name == "PngSuite.README"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_curl_without_unknown_length_download_limits_is_rejected() {
|
||||
for version in ["curl 7.88.1", "curl 8.3.0", "curl invalid", "not curl"] {
|
||||
assert!(!supported_curl(version));
|
||||
}
|
||||
for version in ["curl 8.4.0 libcurl/8.4.0", "curl 8.21.0 (Windows)", "curl 9.0.0"] {
|
||||
assert!(supported_curl(version));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user