Compare commits

..
Author SHA1 Message Date
bflatastic 9dfa7bf931 Docs: ROADMAP 2026-10-12 adds #1474–#1525 (late merges of the day) 2026-10-13 06:56:37 +07:00
BFlat 02a68e1738 Merge pull request #1540 from storytold/fix/1479-too-large-note
Part of #1479: an image too large to decode is reported on Open, Place and in the Links panel
2026-10-13 06:43:25 +07:00
BFlat b631002c58 Merge pull request #1538 from storytold/fix/1499-paste-pdf-eps
Fix #1499: PDF and EPS another app copied paste as editable vector art
2026-10-13 06:36:24 +07:00
BFlat 041776fc9f Merge pull request #1537 from storytold/fix/compound-group-paint
Make Compound Path: the compound path of a group takes its backmost path's paint
2026-10-13 06:36:02 +07:00
BFlat 78155ed5b8 Merge pull request #1498 from lewandowskit/i18n-polish
i18n: add Polish (pl) interface language
2026-10-13 06:34:52 +07:00
BFlat 51c18f603a Merge pull request #1492 from switch87/fix/1484-compact-anchor-heavy-saves
Native save: documents whose JSON is large are written compact
2026-10-13 06:34:35 +07:00
bflatastic 66add30d65 Part of #1479: an image too large to decode is reported on Open, Place and in the Links panel
An image whose decode needs more than the 2 GiB allowance (512 MB on the web) is not drawn.
A raster export says so since #1517; on screen it was still blank with no word. Open and
Place now carry a warning for it, and the Links panel's row a note (links.list: note).
2026-10-13 06:34:12 +07:00
bflatastic b3f6edddfe Fix #1499: PDF and EPS another app copied paste as editable vector art
Paste read PDF only from the Windows clipboard and EPS nowhere, so on macOS a copied
equation or drawing pasted nothing new (the last thing copied in the app came back).
- macOS: the pasteboard's PDF, EPS and SVG are read, ahead of text and bitmaps.
- Windows: EPS is read beside PDF.
- Everywhere: an .eps file copied in a file manager pastes.
- clipboard.importEps loads an EPS into the clipboard as document.open reads one.
2026-10-13 06:34:12 +07:00
bflatastic a046fd30fe Make Compound Path: the compound path of a group takes its backmost path's paint
A group has no paint of its own, so a compound path made of one (outlined type) had none
and drew nothing until a fill was set. The paint now comes from the backmost path, or
from the compound path that painted it.
2026-10-13 06:34:12 +07:00
bflatastic 1e5f430ac5 Polish: the strings added since the catalog was written 2026-10-13 06:34:12 +07:00
Gert Pellin f3040e3762 Contributors: add my real name 2026-10-13 06:34:12 +07:00
Tomasz LewandowskiandClaude Opus 5.5 db54eefeae i18n: add Polish (pl) interface language
Complete Polish catalog (4,492 rows): every menu string, tl! literal,
plural and status/error message, enforced by the existing completeness
tests (complete_menus, KEEPS_MENU_NAMES, COMPLETE_MESSAGES).

- pl.tsv: clean-room translation from the English meaning, following
  common Polish desktop-software conventions (obszar kompozycji for
  artboard, obszar roboczy for workspace, obrys, wypełnienie, próbki…)
- plural_polish: one / few (2–4, not 12–14) / many (0, 5–21, 25…)
- every pl-* locale (pl-PL, pl_PL.UTF-8) resolves to it
- tests: Polish plural forms, vocabulary, nested message translation;
  Polish letters in the bundled-font coverage test
- docs: README, development.md, localization-parity.md

Drafted with AI assistance; native review welcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-13 06:34:12 +07:00
Gert Pellin 37954523ae Native save: documents whose JSON is large are written compact
SaveOptions::for_doc chose pretty-printed JSON by the object count in
the layers alone. A document with few layer objects but many anchors,
or with its art in symbols, patterns or graph designs, was therefore
written pretty, about ten times the compact size: every anchor's "p",
"in" and "out" spread over several indented lines.

A document is now pretty only when it has at most PRETTY_MAX_OBJECTS
objects and its compact JSON is at most PRETTY_MAX_BYTES (1 MiB). The
size is measured by serializing into a writer that stops at the limit,
so a large document costs at most 1 MiB of extra serializing. Small
documents stay readable and diffable.

Fixes #1484
2026-10-13 06:34:12 +07:00
BFlat c391e1dbf2 Merge pull request #1535 from storytold/u/trace-shared
Image Trace: abutting regions share one curve along their edge (no gaps, no overlaps)
2026-10-13 06:27:57 +07:00
bflatastic 20ec49b2e7 Image Trace: abutting regions share one curve along their edge
Each region's outline was fitted on its own, so the edge between two regions was fitted twice, a little
differently, and left hairline gaps and overlaps.

- An outline is cut at its junctions: the pixel corners where three or more regions meet, where two parts of a
  region touch by a corner only, and where an edge reaches the image's border (each side of the image is a
  neighbour of its own, so the image's corners stay exact). A junction lies where the edges around it say, the
  same for every region around it.
- Each piece between two junctions is the whole edge between two regions. Both get exactly the same points for
  it (shifts are rounded to 1/1024 px so that the two sides agree to the last bit; whether a piece is hard is
  decided by the piece alone) and it is fitted the same way from either side (from its end further left), so it
  is one and the same curve in both paths. A loop without junctions is fitted clockwise from its leftmost point:
  a shape's outline and the hole it leaves in the shape around it are one curve too.
- Stacked layers (Overlapping) share no edges and are cut at the image's border only.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- Default: gaps 22.2 -> 0.8 px, overlaps 22.4 -> 0.5 px
- 3 Colors: gaps 48.6 -> 0.0, overlaps 29.4 -> 0.0; outlines 0.087 -> 0.104 px off on average
- 6 Colors: gaps 39.0 -> 0.2, overlaps 35.7 -> 0.3; 0.137 -> 0.140 px off, largest 1.09 -> 1.34
- 16 Colors: gaps 34.5 -> 0.2, overlaps 26.4 -> 0.2; 0.133 -> 0.141 px off, largest 1.09 -> 1.31
2026-10-13 06:00:40 +07:00
BFlat 8715130c8b Merge pull request #1522 from anders94/readme-fix
README.md: add clone instructions to quick start
2026-10-13 05:57:22 +07:00
BFlat 6689be0351 Merge pull request #1526 from AyberkA0/feat/3d-inflate-twist-taper-cap
3D and Materials: Inflate takes Twist, Taper and Cap
2026-10-13 05:56:34 +07:00
BFlat c2c8a07b75 Merge pull request #1525 from AyberkA0/fix/3d-twisted-walls
3D and Materials: twisted Extrude & Bevel walls keep their faces
2026-10-13 05:56:18 +07:00
BFlat b83eafd794 Merge pull request #1524 from AyberkA0/fix/3d-twist-range
3D and Materials: Extrude & Bevel's Twist runs 0° to 360°, as documented
2026-10-13 05:56:03 +07:00
BFlat e93fc20294 Merge pull request #1523 from dotCooCoo/feat/mac-menu-sync-skips-drag-steps
macOS: the app no longer reads the menus at every step of a move, scale or shape drag
2026-10-13 05:55:48 +07:00
BFlat 9e2a7c41a0 Merge pull request #1519 from dotCooCoo/feat/many-path-edits-one-pass
Part of #1429: path, Pathfinder and live-object commands add and remove many objects in one pass
2026-10-13 05:55:31 +07:00
BFlat a4ba47a439 Merge pull request #1515 from dotCooCoo/feat/many-row-edits-one-pass
Part of #1429: Layers panel and artboard commands on many rows take time linear in the document
2026-10-13 05:55:21 +07:00
BFlat 93e8c00607 Merge pull request #1517 from fastmikey99/fix/decode-print-size-rasters
Decode print-size rasters in Crop Image trim and the renderer; say when an export is missing an image
2026-10-13 05:55:05 +07:00
Anders Brownworth 1e0063258a README.md: add clone instructions to quick start 2026-10-13 05:22:01 +07:00
Ayberk A. 15cef94ba6 3D and Materials: Inflate takes Twist, Taper and Cap
The public 3D and Materials panel options list Depth, Twist, Taper and Cap
for every 3D type; Inflate had only Depth.

- Twist (0° to 360°) and Taper (1–100 %) turn and scale the back about the
  shape's centre, as Extrude & Bevel's do: a puffed back is turned and
  lowered in proportion, and the sides follow in slices.
- Cap: Solid closes the back; Hollow leaves it off, so the inflated front is
  a shell open behind. The documentation doesn't say what Cap means for
  Inflate; this reading is stated in docs/three-d.md.
- The dialog's Object tab shows them in the documented order (Depth, Twist,
  Taper, Cap), then Volume and Both sides.

At 0 % volume a twisted, tapered Inflate has the extrusion's outline from
every side (tested).

A twisted, tapered, hollow Inflate draws on the canvas exactly as it
exports, and expands to faces with their own ids (tested). ROADMAP and the
parity pages list the new options; the 3D score stays at ~25 %.
2026-10-13 05:22:00 +07:00
Ayberk A. 431dc43e43 3D and Materials: twisted Extrude & Bevel walls keep their faces
A twist bends the strip between two slices into a saddle; its normal came
from one corner's triangle, which leaned so far back that from about 60° of
twist every wall was taken for facing away and dropped, leaving the front
cap alone. Each strip now takes the whole quad's average normal (Newell's),
as Inflate's facets do. A test checks the faces kept against each strip's
average normal oriented away from the axis, for twists up to 300° and four
views.
2026-10-13 05:21:41 +07:00
Ayberk A. e750891912 3D and Materials: Extrude & Bevel's Twist runs 0° to 360°, as documented
The public 3D and Materials panel options give Twist as up to 360°; it took
-360° to 360°. Values beyond either end now hold at that end (the effect is
a day old, so only fresh files carry a negative twist).
2026-10-13 05:21:32 +07:00
dotCooCoo fb2089aaf5 macOS: the app no longer reads the menus at every step of a move, scale or shape drag
On macOS, native_menu::sync reads every row of the menus (each
command's enabled and checked state, and each label) whenever
state_hash changes, and the macOS backend updates the AppKit items
whose label, enabled or checked state changed. The hash included the
active document's revision, and Session::preview makes a new revision
at each step of a drag, so the app read every row again at each step
of a move, scale or shape drag. In a release build on an M2 Max, that
took about 1.5 ms a step with one object selected, and 13.8 ms a step
with 5,001 objects selected.

While an interaction is open, state_hash leaves the revision out.
Move, scale and shape drags, dialog previews and the font menu's
preview are interactions. The menus are read when an interaction
begins and again on the frame it ends: when a drag's button is
released, when Escape cancels a drag, when a move is dragged onto a
panel, when a dialog's preview is applied or canceled, when the font
menu's preview ends, or when a control request such as
ui.dialog.cancel ends it. They are still read on a click or a key
press during an interaction.

docs/development.md says when the menus are updated during an
interaction.
2026-10-13 05:21:32 +07:00
dotCooCoo 88bd4812e0 Part of #1429: path, Pathfinder and live-object commands add and remove many objects in one pass
Offset Path, Outline Stroke, the Pathfinder panel, Clean Up, Make
Blend, Rasterize and the other commands that #1429's table marks 3,
except Split Into Grid, added, removed or replaced their objects one at
a time, and so did Remove Anchor Points, Redefine Symbol and the
removal of Shaper groups whose source art is gone. Each insertion or
removal moved the later objects of the layer or group. The lookups by
id that followed walked the tree, and every fourth walk rebuilt the
lookup cache. On 25,000 rectangles a release build of vectorcraft-cli
took 29.1 s for Offset Path.

These commands now make their edits with Document::wrap,
Document::insert_beside and Document::remove_many, in one pass over the
children of each layer or group, and Outline Stroke and Expand write
each new object into the place of the object it replaces. A list that
holds an object twice, or an object and another inside it, and that was
edited without an error before, is still edited one object at a time.
The documents, selections and undo steps are the same. On the same
rectangles Offset Path now takes 1.3 s.

Docs: docs/target-app-parity.md.
2026-10-13 05:21:31 +07:00
dotCooCoo 6f9e2d5c16 Part of #1429: Layers panel and artboard commands on many rows take time linear in the document
These commands removed, moved or copied one row or object at a time:
the Layers panel's Delete Selection, Duplicate Selection, Collect in
New Layer, row drags, Merge Selected, Flatten Artwork and Release to
Layers, and Cut Artboard, Duplicate Artboard and the Artboard tool's
Alt-drags that copy art. Each edit moved the later rows of the same
layer or group to other indices. The next lookup by id then walked the
tree, and after every fourth such walk Document::index_path rebuilt
the whole lookup cache. Release to Layers also walked the document
twice for each new layer to name and color it. With 25,000 rectangles
in one layer, a release build of vectorcraft-cli took 11.9 s for
Duplicate Selection and 17.7 s for Release to Layers.

The commands now remove, move and copy the rows in one pass over each
layer or group, with Document::remove_many and Document::wrap, and
with copy_above, the copy pass of duplicate_in, which Duplicate
Selection and the artboard copies now share. Release to Layers names
its new layers from one walk of the document. Merge Selected checks
the target layer's objects against a set of the listed rows, and
Delete Selection checks the top-level layers against such a set. In a
document whose ids are unique, the rows keep their order, new rows and
layers take the same ids, names and colors, and the selection, the
undo step and the journal are the same. A release build with this
change takes 98 ms for Duplicate Selection and 26 ms for Release to
Layers.

Docs: docs/target-app-parity.md.
2026-10-13 05:21:04 +07:00
fastmikey99andClaude Opus 5.5 d084af162f Render: decode print-size rasters, and say when an image is missing from an export
The renderer decoded images with the decoder's default 512 MB allocation
limit, so a 300 ppi roll (6614 x 59055 px, 1.56 GB as RGBA) that opens and
exports to PDF fine drew nothing in a raster export - a fully transparent PNG,
exit code 0, no warning. It now decodes with the allowance opening has (2 GB,
512 MB on the web), shrinks an image past a pixmap's 65535 px side to fit
instead of dropping it, and counts any image it still can't decode: a raster
export that drew fewer images than it should says so in its warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:20:57 +07:00
fastmikey99andClaude Opus 5.5 e01b9a20d9 Crop Image trim: decode print-size rasters, and leave CMYK images alone
Trim decoded the image with the decoder's default 512 MB allocation limit, so
a 300 ppi roll (6614 x 59055 px, 1.56 GB as RGBA) that opens fine failed with
"the image's pixels can't be read". It now decodes with the allowance opening
already has (MAX_RASTER_ALLOC: 2 GB, 512 MB on the web). A CMYK image, kept as
its ink amounts, has no alpha: trim leaves it alone (trimmed: false) instead
of failing to decode it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:20:48 +07:00
BFlat bd9b8a2db3 Merge pull request #1511 from Atalantis/fr-complete-catalog
i18n: complete the French (fr) UI catalog (423 entries)
2026-10-13 05:10:38 +07:00
BFlat c67c25128c Merge pull request #1509 from marcoarnulfo/fix/direct-select-active-anchor-precedence
Direct Selection: a selected path's anchor is picked before one of an object above it
2026-10-13 05:10:23 +07:00
BFlat b9e5536794 Merge pull request #1507 from duydev/feat/vietnamese-localization
G16: Vietnamese interface (vi)
2026-10-13 05:10:08 +07:00
BFlat 15e1f6327f Merge pull request #1502 from dotCooCoo/feat/perf-windows-load-memory
CLI: perf on Windows reports the cores busy and its working set, and the help lists --deep
2026-10-13 05:09:52 +07:00
bflatastic 57a4d9c6e1 French: the shortcut warning starts with a word, as in the other catalogs (the UI fonts have no warning sign) 2026-10-13 05:03:42 +07:00
Florent Siegenthaler e87abf02a8 i18n: align French terms with the names vector illustrators know (188 entries)
Rewords French entries to the feature names French-speaking users of vector illustration software know, as the catalog header asks: Repères commentés, Sélection directe progressive, Trait for Line Segment, Déformation for Shear, Associer for Group, Annuler for Release in the Make/Release submenus, Étape de dégradé, Redéfinir les couleurs de l'illustration, the graph and symbolism tool names. 27 terms are attested in Adobe's public French help pages; the others come from the reviewer's knowledge of the French interface. The eight strings pinned by french_reads_as_french and the MENU_KEEP_AS_IS strings are untouched. Keys with two meanings (Trim, Crop, Merge, Outline, Gradient) are left unchanged: they need @id entries.
2026-10-13 05:03:42 +07:00
Marco Arnulfo e4c2b3db94 Direct Selection: a selected path's anchor is picked before one of an object above it (fixes #1508) 2026-10-13 05:03:41 +07:00
bflatastic 2ec817e76a Vietnamese: the 3D dialog's and 3D effects' rows the other complete catalogs have 2026-10-13 05:03:41 +07:00
Florent Siegenthaler 9e39a6d124 i18n: complete the French (fr) UI catalog (423 entries)
Adds the 423 keys that other catalogs translate and fr.tsv lacked. The 19 MENU_KEEP_AS_IS strings stay out of the catalog, as the menu tests require. Entries follow the existing catalog's wording and typography (no-break space before : ; %, typographic apostrophe); placeholders, edge spaces, trailing ellipses and plural forms are preserved.
2026-10-13 05:03:41 +07:00
Trần Nhật Duy ec8dc0c2da i18n: add complete Vietnamese localization 2026-10-13 05:02:43 +07:00
dotCooCoo 22e06db73d CLI: perf on Windows reports the cores busy and its working set, and the help lists --deep
On Windows, perf printed "load average ?": load_average runs sysctl
or reads /proc/loadavg, and Windows has neither. The warning for a
busy machine could not print, so a run over budget on a busy machine
failed, where macOS and Linux warn and exit 0. perf --deep printed no
memory rows, because rss_mb runs ps. --help did not list --deep.

On Windows, perf now reads GetSystemTimes twice, one second apart,
and its first line gives the cores busy on average over that second
where macOS and Linux give the load average. It warns above three
quarters of the cores, as on macOS and Linux: with all 32 logical
processors of a test machine kept busy, the line read "cores busy
32.0" and a run with three budgets over exited 0. The --deep memory
rows give the working set (GetProcessMemoryInfo). Both calls use
winsafe 0.0.29, which Cargo.lock already lists for the desktop app;
the CLI depends on it on Windows with the kernel and psapi features.
The help lists --deep and says that a run over budget on a busy
machine exits 0. perf's report on macOS and Linux does not change.
2026-10-13 05:02:25 +07:00
BFlat aebeb66268 Merge pull request #1520 from storytold/u/trace-docs
Image Trace: progress docs for the palette and photo changes
2026-10-13 04:59:45 +07:00
bflatastic cf137c8c90 Docs: Image Trace's colour and photo presets, measured (ROADMAP log, parity evidence) 2026-10-13 04:42:29 +07:00
BFlat e131dfbb1c Merge pull request #1516 from storytold/u/trace-photo
Image Trace: photos trace into smooth tones (smoothing first, stacked layers by lightness)
2026-10-13 04:42:08 +07:00
bflatastic 77f81c8944 Image Trace: photos are smoothed within their tones first, and stacked layers go by lightness
A photo's noise is as large as the step from one palette colour to the next, so noise decided pixel by pixel
which of two neighbouring colours a pixel got: tens of thousands of specks, or (since the palette change)
neighbouring tones merged into one.

- A continuous-tone image (eight colours leave much of it unsaid; flat art, however noisy or unevenly lit, is
  not one) is smoothed before it is quantised: three rounds of a small bilateral filter, which averages noise
  away within a tone and keeps the edges between tones where and as sharp as they were.
- With Method: Overlapping, layers are stacked by lightness, from the largest colour's end of the scale,
  instead of by area. Each layer reaches under those above it, so where two colours meet every layer between
  them in the stack ends on that same edge and shows through its anti-aliasing: by area that was an unrelated
  colour's hairline around every region; by lightness it is a neighbouring tone.
- Ignore White with Overlapping: no layer reaches under the white that is left out.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- High Fidelity Photo: colour error 2.37 -> 1.75 (2.30 at the baseline), edge error 2.59 -> 1.98, 639 -> 121
  paths (3,653 at the baseline), 25,604 -> 9,818 anchors, specks 152 -> 10; a 16 megapixel photo-like image is
  traced in 3.5 s (31,131 paths) where it was refused
- Low Fidelity Photo: colour error 2.67 -> 2.45, 55 -> 37 paths
- Shades of Gray: 73 -> 17 paths, colour error 9.33 -> 9.26
- The other presets' numbers are unchanged
2026-10-13 04:28:02 +07:00
BFlat 7fadeb4ed2 Merge pull request #1485 from webdevtodayjason/graph-csv-precision
Graph Data: the CSV keeps each value exactly, not rounded to two decimals
2026-10-13 04:06:03 +07:00
BFlat 396d7d2b76 Merge pull request #1487 from itrycodinganditsbad/fix-1458
Tabs panel: drag a tab stop from where the press landed
2026-10-13 04:05:48 +07:00
BFlat e30b1492a1 Merge pull request #1506 from storytold/fix/1483-slider-rails
Fix #1483: sliders in dialogs show their rail
2026-10-13 04:05:31 +07:00
BFlat e6ae1d0df3 Merge pull request #1505 from storytold/fix/1486-template-outline
Fix #1486: a template layer stays in Preview in Outline view
2026-10-13 04:05:18 +07:00
webdevtodayjason 43e1a7380c Graph Data: the CSV keeps each value exactly, not rounded to two decimals 2026-10-13 04:00:18 +07:00
Fabio LussiandClaude Opus 5.5 7ab9a46bf8 Tabs panel: drag a tab stop from where the press landed
egui reports drag_started only once the pointer is past its drag
threshold, farther from the press than the ruler's 5 px hit tolerance,
so hit-testing the current pointer usually missed the stop. Hit-test the
press origin instead, falling back to the pointer position.

Fixes #1458

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:00:17 +07:00
bflatastic dd1c26b627 Part of #1483: sliders in dialogs show their rail
The theme's widget fill matches the dialog background, so the sliders of
Save for Web, the raster export options, Preferences and the opacity
popup showed only their handle. They share one slider widget now, with a
visible rail filled up to the handle.
2026-10-13 04:00:17 +07:00
bflatastic a787fb487c Fix #1486: a template layer stays in Preview in Outline view
In Outline view a template layer drew as outlines like any other, so a
picture placed to be traced turned into a box. It now keeps drawing in
Preview (dimmed as before) while the other layers are in outline.
2026-10-13 04:00:17 +07:00
BFlat ce0c86f91a Merge pull request #1504 from storytold/u/trace-palette
Image Trace: colour presets trace flat art with its own colours (no shattering, no slivers)
2026-10-13 03:55:45 +07:00
bflatastic 4204ce36db Image Trace: a palette of the picture's own colours
Color and Grayscale chose their colours from every pixel, so the blends along anti-aliased edges took palette
entries and traced as slivers, and with more colours asked for than the art has, k-means split flat colours
along their noise and the halves traced as specks all over each other.

- The palette is made from the picture's own colours: a pixel that lies between the pixels on opposite sides
  of it is a blend and counts for nothing (lines one pixel wide and noise are no blends).
- Colours that noise made of one colour merge: close colours whose pixels are mixed through each other. The
  bands of a gradient keep to themselves and stay apart.
- A blend pixel whose nearest palette colour is a third colour (halfway from navy to yellow lies an olive that
  is nearer to a green elsewhere) goes to the nearer of the two colours it lies between.
- Boxes are cut where their two sides differ most (Otsu's criterion) instead of at the median, which split the
  largest colour of flat art along its noise and left two smaller colours in one box.
- Colours are compared in Oklab when pixels are assigned and in k-means.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- 16 Colors: 69.9 -> 4.2 paths, 1743 -> 66 anchors, outlines 5.08 -> 0.13 px off on average (largest 85.9 ->
  1.09), kinks 1376 -> 4.4, paths in a colour the art doesn't have 5.9 -> 0
- 6 Colors: 21.2 -> 4.0 paths, 4.44 -> 0.14 px off; 3 Colors: colour error 4.86 -> 0.43, 0.33 -> 0.09 px off
- High Fidelity Photo: 3636 -> 639 paths, 81,597 -> 25,604 anchors, colour error 2.50 -> 2.37; a 1 megapixel
  photo-like image 1.98 -> 0.42 s (51,389 -> 4,298 paths), a 4 megapixel one traced (27,583 paths) where it was
  refused
- Low Fidelity Photo: 128 -> 55 paths, colour error 2.59 -> 2.67; Shades of Gray: 91 -> 73 paths, 9.29 -> 9.33
2026-10-13 03:48:30 +07:00
BFlat a891530ef2 Merge pull request #1500 from storytold/fix/1471-text-overflow
Fix #1471: making and restyling type reports text that doesn't fit its frame
2026-10-13 03:44:26 +07:00
BFlat c372585b73 Merge pull request #1501 from storytold/docs/progress-1013a
Docs: ROADMAP 2026-10-12 adds #1430–#1497 (later merges of the day)
2026-10-13 03:43:38 +07:00
bflatastic 8b8fd1476b Fix #1471: making and restyling type reports text that doesn't fit its frame
text.create, text.createInPath, text.setText and text.setStyle answer with
overflow: [id…] for the area type and type on a path whose text doesn't fit (left
out when all fits), and MCP add_text with overflow: true. add_text takes area
type's fit and fitMinPercent.
2026-10-13 03:27:13 +07:00
BFlat 214312f883 Merge pull request #1481 from AyberkA0/fix/3d-curve-sampling
3D and Materials: curves follow a tolerance, and Rotate takes large outlines
2026-10-13 03:27:03 +07:00
BFlat 2c74cd928a Merge pull request #1474 from Skaywalker/i18n-hu-review
i18n(hu): informal address in a row added on merge
2026-10-13 03:26:49 +07:00
bflatastic c1be8b82db Docs: ROADMAP 2026-10-12 adds #1434–#1497 (later merges of the day)
Progress log, evidence, checklist rows and revision history for the
changes merged late on 2026-10-12 that the progress docs didn't have.
2026-10-13 03:25:37 +07:00
bflatastic c3c036daf2 3D and Materials: the edge index's search from outside its grid looks along the side
A point outside the grid can be nearer an edge further along the grid's side than one
straight ahead of it; the search stopped a ring early there. The bucket lookup is one
function.
2026-10-13 03:14:50 +07:00
Skaywalker 905a965d1e i18n(hu): informal address in a row added on merge
One message translated when #1302 was merged addressed the user with
the formal verb form ("csatolja"); the catalog uses the informal second
person throughout ("csatold").
2026-10-13 03:14:50 +07:00
Ayberk A. b3ada24da7 3D and Materials: curves follow a tolerance, and Rotate takes large outlines
Extrude & Bevel, Inflate and Rotate sampled every curve at sixteen points and
refused a shape past 1,024 points, about 64 curves: outlined words reached
that quickly, and Rotate refused them although it only turns the outline.

- Each curve takes as many straight pieces as keep it within 1/2000 of the
  shape's size (at least 0.05 pt), by Wang's bound: 1 to 32.
- Extrude & Bevel and Inflate take up to 2,048 points, Rotate 65,536.
- Inflate's distance and crossing tests use a bucketed edge index, and
  cells no edge crosses skip clipping: about 80 ms at 2,000 points (it was
  over 600 ms with the old search; 95 ms at the old 1,024-point limit).

Revolve's own sampling is unchanged.
2026-10-13 03:14:50 +07:00
BFlat 1eb9b34439 Merge pull request #1497 from storytold/w/1457-field-follows-value
Fix #1457: a focused field follows its value when a slider or preview changes it
2026-10-13 03:11:54 +07:00
BFlat b49484af08 Merge pull request #1496 from storytold/w/1442-unknown-params
Fix #1442: an unknown parameter key is an error naming it and the nearest accepted key
2026-10-13 03:11:44 +07:00
BFlat 908b4ddcbc Merge pull request #1495 from storytold/w/1459-surface-limit
Part of #1459: the window's surface may be as large as the graphics adapter allows
2026-10-13 03:11:34 +07:00
BFlat fcc52c3c7b Merge pull request #1494 from storytold/w/1448-transform-objects
Fix #1448, #1460: Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle
2026-10-13 03:11:24 +07:00
BFlat 499cd4f30c Merge pull request #1493 from storytold/w/1450-pathfinder-group
Fix #1450: the Pathfinder panel works on a group selected alone
2026-10-13 03:11:14 +07:00
bflatastic 3672e0ae84 Fix #1457: a focused field follows its value when a slider or preview changes it 2026-10-13 02:54:33 +07:00
bflatastic 3acc29f8e0 Fix #1442: an unknown parameter key is an error naming it and the nearest accepted key 2026-10-13 02:54:33 +07:00
bflatastic ff6e136b97 Part of #1459: the window's surface may be as large as the graphics adapter allows 2026-10-13 02:54:08 +07:00
bflatastic 897d99a70f Fix #1448, #1460: Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle 2026-10-13 02:54:08 +07:00
bflatastic 7625feea08 Fix #1450: the Pathfinder panel works on a group selected alone 2026-10-13 02:54:08 +07:00
BFlat e74cb01ecc Merge pull request #1490 from storytold/fix/1465-selected-text
Part of #1465: selected text in value fields is readable
2026-10-13 02:49:42 +07:00
BFlat 67af0cdf82 Merge pull request #1489 from storytold/fix/1466-gray-alpha-tiff
Part of #1466: grayscale TIFFs with an alpha channel or extra channels can be placed
2026-10-13 02:49:31 +07:00
BFlat 3dbb551db3 Merge pull request #1488 from storytold/fix/1462-split-into-grid
Fix #1462: Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview
2026-10-13 02:49:20 +07:00
bflatastic 09f62097f0 Part of #1465: selected text in value fields is readable
Selected text in a numeric or text field was drawn in the accent colour on the accent-tinted
selection, in every brightness. The fields that draw their own frame (panel and Control bar fields,
dialog fields, the hex field) now draw it in the strong text colour.
2026-10-13 02:48:41 +07:00
bflatastic 81a568f4c6 Part of #1466: grayscale TIFFs with an alpha channel or extra channels can be placed
File > Place, Relink and Open refused a grayscale TIFF whose pixels carry an alpha channel or
another extra channel: the image decoder reads neither. They are now read like CMYK TIFFs with
alpha: the first extra sample is the transparency when the file says it is alpha (associated alpha
divided back out), and other channels are left out.
2026-10-13 02:48:39 +07:00
bflatastic cb9d93b647 Fix #1462: Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview
Object > Path > Split Into Grid took a number of rows and columns and one gutter, and split each
selected path on its own. The dialog now has the documented options, the rows beside the columns:
number, height or width, gutter and total, with Add Guides and Preview. Several selected paths
become one grid over them all, painted as the topmost one, as documented.

object.path.splitIntoGrid takes rowGutter and columnGutter (gutter still sets both), totalHeight
and totalWidth, height and width (the size of each row and column, which sets the total) and
addGuides, which adds ruler guides along the row and column edges on the grid's layer.

Behaviour reference:
https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html
2026-10-13 02:48:36 +07:00
BFlat 974325b62a Merge pull request #1464 from dotCooCoo/feat/alt-drag-copies-once
Part of #1429: Alt-drags with the selection and transform tools copy the objects once per drag
2026-10-13 02:48:23 +07:00
BFlat 3a8e1ec0f8 Merge pull request #1463 from webdevtodayjason/fix/plugin-default-range
Plug-ins: numeric defaults stay in their declared range
2026-10-13 02:48:07 +07:00
BFlat 05667c40b1 Merge pull request #1461 from Losminek/cs-sync
i18n(cs): catch up with strings added since the Czech catalog
2026-10-13 02:47:52 +07:00
dotCooCoo 571289ddb6 Part of #1429: Alt-drags with the selection and transform tools copy the objects once per drag
On each pointer move of an Alt-drag with the Selection, Direct
Selection, Group Selection, Rotate, Scale, Reflect or Shear tool,
Session::preview put the drag's snapshot back and ran object.transform
with copy again, and duplicate_in copied every selected object again.
The Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview
on copied every selected object again on each change of a value.

The interaction now keeps the untransformed copies that its first
preview with copy makes of the snapshot. Each later preview that
copies the same objects of the same snapshot transforms only those
copies. The copies keep their ids, and the document, the selection,
the undo step and the journal are the same as before. With 25,000
rectangles selected, a release build of vectorcraft-cli took a median
of 138.5 ms for each Alt-drag move after the first preview and takes
105.7 ms with this change.

Docs: docs/target-app-parity.md.
2026-10-13 02:21:35 +07:00
webdevtodayjason 08c8c11798 Plug-ins: numeric defaults stay in their declared range 2026-10-13 02:21:35 +07:00
LosminekandClaude Opus 5.5 847730bf8b i18n(cs): catch up with strings added since the Czech catalog
Adds 127 Czech entries the Hungarian/Spanish/German catalogs already
have: tool short names, recolor and swatch library labels, print/PDF
preset names and their differences lines, pie graph options, counts
and the about-box credits. "Reset <workspace>" entries keep the
built-in workspace names in English as MENU_KEEP_AS_IS requires.
Also [Default] -> [Výchozí] in the protected-preset hint, matching the
existing [Default] entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:21:35 +07:00
BFlat 0d8b08ccd4 Merge pull request #1478 from storytold/u/trace-subpixel
Image Trace: edges to a fraction of a pixel in Black and White, Grayscale and Color
2026-10-13 02:06:03 +07:00
bflatastic fcd7e3b42d Image Trace: edges to a fraction of a pixel in Black and White, Grayscale and Color
Each crack between an inside and an outside pixel is moved to where the pixels across it say the edge is (the
blend of the two tones in the column of pixels across the crack; a thin line by the centroid and sum of its
tone; hard-edged images are read as staircases), and the outline is fitted as Flat Logo mode fits its own:
corners put back, straight runs as lines, a round outline as a circle, the rest as few curves as Paths allows.
Snap Curves To Lines straightens slightly curved runs and makes arcs. A fit that strays from its outline
gives way to a plainer one, and at last to the outline's own points.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md):
- Default: outlines 0.313 -> 0.091 px off on average, largest 1.51 -> 1.02; edge error 17.2 -> 10.1;
  gaps between abutting paths 63.5 -> 22.2 px, overlaps 67.0 -> 22.4 px
- Black and White Logo: 0.270 -> 0.064 px off, kinks 15.3 -> 0.3, anchors 3.78 -> 2.73 times the art's
- Technical Drawing: 0.201 -> 0.086 px off; Line Art: 0.158 -> 0.093
- Worse: anchors on blurred and scanned thin lines (Line Art 25 -> 77 on average), High Fidelity Photo
  (colour error 2.30 -> 2.50, anchors 46,866 -> 81,597, 1 megapixel photo-like image 0.37 -> 1.98 s)
2026-10-13 01:55:17 +07:00
bflatastic a17e3057de Image Trace quality harness: an aliased variant, and every drawing can be written out 2026-10-13 01:45:57 +07:00
BFlat 82ac5c92ea Merge pull request #1453 from AyberkA0/feat/3d-rotate-extrude-inflate
Effect › 3D and Materials: Extrude & Bevel, Inflate and Rotate
2026-10-13 01:38:27 +07:00
BFlat 7718b62735 Merge pull request #1452 from dotCooCoo/feat/raster-effects-follow-document-edits
Render: raster effects are made again when patterns, symbols, images, settings or fonts change
2026-10-13 01:38:14 +07:00
BFlat 63e0bff91d Merge pull request #1451 from dotCooCoo/feat/text-frames-huge-coordinates
Type: frames and paths beyond 1,000,000,000 pt hold no text, and layout limits its search for room
2026-10-13 01:37:59 +07:00
BFlat e2177c0d3a Merge pull request #1445 from giang17/fix-fitcheck
Fix #1444: text.fitHeadline sees wrapping in a frame one line tall
2026-10-13 01:37:37 +07:00
bflatastic 2cf2b972f5 3D and Materials: the bevel and rotation options take their documented names, and the new messages are in every catalog
Bevel Shape, Space, Bevel Inside, Bevel both sides and Presets, as the public 3D and Materials panel
options page names them. The 3D effects' error messages get rows in the twelve complete catalogs.
2026-10-13 00:52:40 +07:00
Ayberk A. 8a3458a974 Effect › 3D and Materials: Extrude & Bevel, Inflate and Rotate
The three 3D types besides Revolve, as live effects in the three-d crate,
drawn as shaded vector faces by the canvas, SVG/PDF export and Expand
Appearance alike, from the public 3D and Materials documentation:

- Extrude & Bevel: depth (0-2000 pt), twist, taper, solid or hollow caps,
  and bevels in six original cross-sections (classic, rounded, cove, step,
  ogee) with width, height, repeat, spacing, inside and both sides.
- Inflate: the shape as a membrane under pressure (a Poisson solve held at
  zero along the outline and holes), so it rises vertically from the edge
  and rounds without creases; volume, depth and both sides. Steep cells
  near the outline are refined without cracks.
- Rotate: every fill and stroke turned in 3D and shaded as one plane.

All three share Revolve's view and lighting parameters, the rotation and
light gizmos (turning about the art's centre) and a dialog with Object,
Rotation and Lighting tabs, with Position presets (Front ... Isometric
Bottom, our own values). An object takes one 3D effect. Inflate cuts the
grid cells that straddle a hole's edge with pathops, hence the new
pathops -> three-d layer order.

Docs: docs/three-d.md, mcp.md, and the 3D rows of the parity docs.
2026-10-13 00:35:07 +07:00
dotCooCoo f4e72fe6a5 Render: raster effects are made again when patterns, symbols, images, settings or fonts change
The renderer kept the rasters of an object's drop shadows, outer glows
and filtered content (Gaussian Blur, Feather and the Photoshop-style
filters), and drew them again while the object, the zoom and rotation
and the ink plane stayed the same. The content drawn into a raster also
reads things outside the object: patterns, symbols, brushes, images,
document, layer and color settings, the knockout a group inherits, the
fonts, and the options the frame is drawn with. When one of them
changed, a renderer that had drawn the object went on drawing the
raster it had kept, made from the old values.

Each kept raster now records what its content was drawn from: which of
those things the object reads, found by walking the object when the
raster is made, and a hash of their values then. The renderer draws
the raster only while that hash is the same in the current frame. Once
a frame, the renderer hashes the document's patterns, symbols and
images. It hashes their art and pixels by the addresses of their
shared values, and it keeps weak references to those values while a
raster made with them is kept, so no other value can take one of those
addresses. A filtered raster also records the color channels its
filters split, and under De-Interlace the raster resolution.

A temporary release test timed the inputs of 400 kept rasters at about
10 microseconds a frame.
2026-10-13 00:34:41 +07:00
dotCooCoo 8754646784 Type: frames and paths beyond 1,000,000,000 pt hold no text, and layout limits its search for room
Opening an EPS whose threaded area type frame has a control point at
x = 8e115 failed with an internal error ("capacity overflow").
`eps_text_frames_never_panic` in `import_fuzz` found it with
PROPTEST_CASES=20000. Text layout flattens a frame to within 0.1 pt,
and for such a curve kurbo 0.13.1's `flatten` calls `reserve` for
`usize::MAX` quadratic pieces. Laying out text around a wrap shape with
such a curve failed the same way. Laying out type on a path along such
a curve panicked in kurbo's `solve_itp` ("attempt to shift left with
overflow") in builds with overflow checks, and in a release build it
did not finish within 20 s. Laying out area type in a triangle frame
that comes to a point at its top, at y = 1e20, did not finish either:
adding a lead to a baseline that large leaves the baseline where it is.

Text layout now leaves out a frame, a text wrap shape or the path of
type on a path that has a coordinate beyond 1,000,000,000 pt either
way, control points included, or a NaN coordinate in an anchor point.
Such a frame or path holds no text, and such a wrap shape is ignored.
PDF export uses the same bound (`MAX_COORD` in
`crates/pdf/src/export.rs`).

Text layout also steps a line down a frame cell at most 100,000 times
to find room for it. In a cell taller than 100,000 leads, a step is the
cell's height divided by 100,000; in a shorter cell, a step is one
lead, as before. Before this change, in a dev build, with triangle
frames that come to a point at their top, laying out text in one
2,000,000,000 pt tall took 9.8 s, and laying out a paragraph whose
Space Before is -1e300 pt in one 1,000 pt tall did not finish within
20 s.
2026-10-13 00:34:40 +07:00
Giang Nguyen 2b34554ac8 fix(text): fitHeadline sees wrapping in a frame one line tall
The layout drops lines that overflow an area frame vertically, so counting
the first paragraph's laid-out lines cannot see a wrapped second line
when the frame holds only one. The bisection then found every tracking
"one line" and ran to its 2000 ceiling, which spread the line far past the
frame (most glyphs clipped away) while the command reported success.

one_line now asks whether the first line reaches the end of the first
paragraph, which holds regardless of dropped overflow lines. Regression
test: area 240x50 at size 36 previously returned tracking 1999.9, now
fills the width (avail - w < 5) with the paragraph on one line.
2026-10-13 00:34:40 +07:00
BFlat 9f0a91b638 Merge pull request #1449 from storytold/fix/1423-affinity-short-frame
Fix #1423: an Affinity text frame too short for its first line is made taller instead of showing nothing
2026-10-13 00:30:41 +07:00
BFlat f2c7c17eb1 Merge pull request #1455 from storytold/u/trace-harness
Image Trace: a quality harness and the baseline for every preset
2026-10-13 00:12:25 +07:00
bflatastic 182a2cda4b Image Trace: a quality harness and the baseline numbers for every preset
crates/trace/tests/quality draws its inputs from known vector art (no image files), traces them with every
built-in preset and measures fidelity, distance from the true outlines, economy, artefacts, time and memory.
docs/image-trace-quality.md records the numbers on main before any change, beside the same measures of an
open tracer run as a black box. No change to the tracer.
2026-10-12 23:56:36 +07:00
BFlat 80af380a6a Merge pull request #1430 from dotCooCoo/feat/many-object-edits-one-pass
Part of #1429: Edit and Object commands on many objects take time linear in the document
2026-10-12 23:52:06 +07:00
BFlat 9251edf1dc Merge pull request #1447 from storytold/docs/progress-1012g
Docs: ROADMAP 2026-10-12 adds #1397–#1428 (later merges of the day)
2026-10-12 23:51:48 +07:00
bflatastic 988f52ecc8 Fix #1423: an Affinity text frame too short for its first line is made taller instead of showing nothing 2026-10-12 23:42:56 +07:00
dotCooCoo ffa948dcc3 Part of #1429: Edit and Object commands on many objects take time linear in the document
Delete, Cut, Group, Make Compound Path, Make Clipping Mask, Send to
Current Layer, Ungroup and Release Compound Path removed, inserted or
moved one object at a time. duplicate_in inserted one copy at a time;
Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs,
Transform Again after a copy, Transform Each with Copy, the perspective
copies and each move of an Alt-drag call it. Each insertion or removal
moved the later objects of the same layer or group to other indices.
The next lookup by id then walked the tree, and after every fourth such
walk Document::index_path rebuilt the whole lookup cache. Each command
did work that grew with the number of objects times the size of the
document: with 25,000 rectangles selected, a release build of
vectorcraft-cli took 7.2 s to group them and 11.5 s to copy them with
object.transform.

Document::remove_many, Document::wrap and Document::insert_beside
remove nodes, wrap them in a new node, or put nodes directly above or
in place of their anchors, in one pass over the children of each
parent. Each finds the nodes with one index_paths call, checks every id
before it changes anything, and clears the lookup cache once. The
commands above use them. The objects keep their order, the copies take
their ids in the same order, and the selection, the undo step and the
journal are the same. duplicate_in still copies one root at a time when
a root is listed twice or lies inside another root. A release build
with this change groups the 25,000 rectangles in 27 ms and copies them
in 121 ms.

Docs: docs/target-app-parity.md.
2026-10-12 23:42:43 +07:00
bflatastic 25c33b6927 Docs: ROADMAP 2026-10-12 adds #1397, #1402, #1404, #1409, #1413–#1415, #1418, #1426–#1428 2026-10-12 23:42:39 +07:00
BFlat baf9693f0f Merge pull request #1440 from EricKuck/feat/ai-open-from-editing-data
.ai import: editingData "always" and convert --editing-data open a file from its editing data even where its page draws differently
2026-10-12 23:29:22 +07:00
BFlat e01a0a70be Merge pull request #1439 from EricKuck/feat/ai-editing-data-art-styles
.ai import: art styles, a clipping path's own fill, compound paths of looks and a group's own transparency from the editing data
2026-10-12 23:29:06 +07:00
BFlat 911c65de34 Merge pull request #1438 from EricKuck/feat/ai-editing-data-gradients-meshes
.ai import: radial and skewed gradient matrices, focal points and stop order, gradient meshes and artboard names from the editing data
2026-10-12 23:28:49 +07:00
BFlat 83d271fb89 Merge pull request #1435 from EricKuck/feat/svg-knockout-in-order
SVG export: knockout groups of opaque art draw in order, without masks
2026-10-12 23:28:36 +07:00
BFlat 8811cc552e Merge pull request #1434 from EricKuck/fix/pdf-import-group-fill-stroke
PDF import: an editor's .ai keeps its RGB numbers, and a fill faded or masked by its group keeps its stroke apart
2026-10-12 23:28:19 +07:00
bflatastic 9e4ed2d430 Catalog rows for the trusted editing data note; the fuzz reads hostile editing data trusted too 2026-10-12 23:08:58 +07:00
EricKuck 42c923d62a .ai import: editingData "always" and convert --editing-data open a file from its editing data even where its page draws differently 2026-10-12 23:07:25 +07:00
EricKuck 939bc02f6b .ai import: art styles, a clipping path's own fill, compound paths of looks and a group's own transparency from the editing data 2026-10-12 23:07:05 +07:00
EricKuck 77120ada62 .ai import: radial and skewed gradient matrices, focal points and stop order, gradient meshes and artboard names from the editing data 2026-10-12 23:06:51 +07:00
EricKuck 92f117cf1d vectorcraft-cli: --decimals sets the SVG coordinate precision 2026-10-12 23:06:36 +07:00
EricKuck 5b413448d9 SVG export: knockout groups of opaque art draw in order, without masks 2026-10-12 23:06:34 +07:00
EricKuck 34b3c59a03 PDF import: an editor's .ai keeps its RGB numbers, and a fill faded or masked by its group keeps its stroke apart 2026-10-12 23:06:19 +07:00
BFlat 04bc43c802 Merge pull request #1428 from storytold/q/1412-transform-dialog-preview
Fix #1412: the Transform dialogs preview on the canvas, and arrow keys step their angles and percentages
2026-10-12 21:10:53 +07:00
BFlat 57e7cd3b0e Merge pull request #1427 from storytold/q/1408-transform-anchors
Fix #1408: the Transform tools and commands act on the anchors selected with Direct Selection
2026-10-12 21:10:37 +07:00
BFlat 487cc29b57 Merge pull request #1426 from storytold/q/1410-ai-compound-paint
Fix #1410: an .ai compound path takes its last path's paint, and paths grouped in it stay its paths
2026-10-12 21:10:21 +07:00
bflatastic 4d17702536 Fix #1412: the Transform dialogs preview on the canvas, and arrow keys step their angles and percentages 2026-10-12 21:01:15 +07:00
bflatastic a41d59604b Fix #1408: the Transform tools and commands act on the anchors selected with Direct Selection 2026-10-12 21:01:15 +07:00
bflatastic 003234a056 Fix #1410: an .ai compound path takes its last path's paint, and paths grouped in it stay its paths 2026-10-12 21:01:15 +07:00
BFlat c70bd33de0 Merge pull request #1418 from Messhias/docs/1417-mcp-guide-sync
Docs: sync mcp.md with acting prefs and shipped command families
2026-10-12 20:50:24 +07:00
bflatastic 3da2685065 Docs: correct the hyphenation exceptions and appendConverted notes in mcp.md (#1417) 2026-10-12 20:40:44 +07:00
Fabio William Conceição 2c508ea867 Docs: sync mcp.md with acting prefs and shipped command families (#1417). 2026-10-12 20:39:57 +07:00
BFlat 54afb67b4f Merge pull request #1409 from vbasky/fix/1396-fat-stroke
Fix #1396: a stroke wider than its closed shape paints solid
2026-10-12 20:36:00 +07:00
BFlat 22f14bec24 Merge pull request #1404 from vbasky/fix/1385-paste-picture
Paste the picture when the clipboard text beside it is blank.
2026-10-12 20:22:21 +07:00
bflatastic 1168c319b0 Close the hole whenever the inner offset of a closed stroke has crossed over
The check added for #1396 dropped the inner contour only when the path itself fell in the hole, which takes a stroke more than twice as wide as the shape. Between one and two times the size (a 30 pt ellipse with a 50 pt stroke), and on rectangles with round or bevel joins, the hole stayed.

An offset contour is now left out when no point of it lies half the weight from its subpath: it is then nowhere the edge of the stroke. Subpaths are judged one by one, and only when the stroke is at least as wide as the subpath's bounds, so ordinary strokes take the same single call as before.
2026-10-12 20:22:07 +07:00
BFlat c26ff9a22a Merge pull request #1402 from vbasky/fix/1229-corner-bounds
Hide the selection bounds while a corner-radius drag is underway.
2026-10-12 20:22:04 +07:00
Vikram Bhaskaran aed5d380e9 Cover a small rectangle in the fat-stroke test. 2026-10-12 20:09:53 +07:00
Vikram Bhaskaran b4c1cdbcea Fill the hole a stroke leaves when it is wider than the closed shape.
A closed path stroked wider than it is came back as a ring, because the inner offset crossed the middle. When that outline no longer covers the path, the outer contour is kept so the stroke paints solid.
2026-10-12 20:09:53 +07:00
BFlat 2a0a279c93 Merge pull request #1415 from storytold/fix/isolate-missing-id
object.isolate: an id that doesn't exist says so
2026-10-12 20:06:24 +07:00
bflatastic ec38b00a22 object.isolate: an id that doesn't exist is reported as such 2026-10-12 20:06:04 +07:00
BFlat d6bace14c3 Merge pull request #1397 from vbasky/fix/1389-inspect-paint
Report compound-path paint and a non-normal blend in document summaries.
2026-10-12 20:05:59 +07:00
bflatastic a366e8ee0d Part of #1389: a compound path's members say so instead of claiming no paint; docs 2026-10-12 20:05:34 +07:00
bflatastic e72a010e1c Merge remote-tracking branch 'origin/main' into K-1397ff 2026-10-12 20:05:33 +07:00
BFlat 92ed381625 Merge pull request #1414 from storytold/fix/1387-ai-text-only-page
Fix #1387: a .ai whose PDF part draws only text opens from it
2026-10-12 20:04:59 +07:00
BFlat 772f056f9c Merge pull request #1413 from storytold/fix/1388-pdf-import-overprint
Fix #1388: PDF and .ai import and file.place keep overprint
2026-10-12 20:04:39 +07:00
bflatastic 7f5d8a3db1 Fix #1387: a .ai whose PDF part is only text opens from it 2026-10-12 19:50:53 +07:00
bflatastic bfb173e967 Fix #1388: PDF import keeps overprint 2026-10-12 19:50:53 +07:00
Vikram Bhaskaran f908545204 Paste the picture when the clipboard text beside it is blank.
Hosts put whitespace in the text flavour ahead of the bitmap, and that empty text was taken as the paste.
2026-10-12 19:50:12 +07:00
Vikram Bhaskaran bf2b5643dc Hide the selection bounds while a corner-radius drag is underway.
The sharp outer box stayed on screen because a corner drag was not treated as a transform.
2026-10-12 19:50:12 +07:00
BFlat 5e4fc4e3af Merge pull request #1405 from storytold/docs/progress-1012f
Docs: ROADMAP 2026-10-12 adds #1375–#1395 (community PRs of the day)
2026-10-12 19:45:30 +07:00
Vikram Bhaskaran 1b79120568 Report compound-path paint and a non-normal blend in document summaries.
document.inspect skipped fill and stroke on compound paths because they are containers, and it never wrote the blend mode.
2026-10-12 23:41:01 +11:00
bflatastic 106925c74b Docs: ROADMAP 2026-10-12 adds #1375, #1376, #1379, #1382, #1383, #1386, #1390–#1392, #1395 2026-10-12 19:36:41 +07:00
BFlat e22a11eda8 Merge pull request #1395 from storytold/dialog-focus-first-enabled
A just-opened dialog gives its focus to the first enabled field, not a greyed-out one
2026-10-12 19:30:53 +07:00
bflatastic 689ad46999 A just-opened dialog gives its focus to the first enabled field, not a greyed-out one 2026-10-12 19:30:20 +07:00
BFlat 2ff64993eb Merge pull request #1394 from storytold/grid-tool-options
Part of #1377: Rectangular Grid and Polar Grid tool options (skew, frame, compound path, fill)
2026-10-12 19:30:16 +07:00
bflatastic ddebe5e479 Part of #1377: Rectangular Grid and Polar Grid tool options (skew, frame, compound path, fill) 2026-10-12 19:22:36 +07:00
BFlat dc640f3d61 Merge pull request #1393 from AyberkA0/feat/double-click-isolate
Isolation mode: double-clicking a single object isolates it
2026-10-12 19:19:47 +07:00
BFlat ce55aaf2fe Merge pull request #1386 from dotCooCoo/feat/symbol-instance-render-cache
Render: the placed art of symbol instances and inline graphics is kept between frames
2026-10-12 19:19:04 +07:00
BFlat 718751b658 Merge pull request #1384 from dotCooCoo/fix/issue-1257
Fix #1257: a dragged object reuses its filter and shadow rasters
2026-10-12 19:18:45 +07:00
BFlat 28e694623d Merge pull request #1383 from Nael-Nathanael/fix/control-port-1373
Fix #1373: --control without a valid port stops the desktop before it starts
2026-10-12 19:18:30 +07:00
Ayberk A. 3073f0ab03 Isolation mode: double-clicking a single object isolates it
With Double Click To Isolate on, the Selection tool isolated groups only. A
double-click on a single path, compound path, image, mesh or placed file now
isolates it too, as the public documentation describes. Type still switches to
the Type tool; blends, envelopes, repeats and symbol instances keep their own
editing modes.

While a single object is isolated only it is hit, marqueed and taken by Select
All, new art goes beside it in its group or layer, and a double-click on the
empty canvas leaves with it selected. object.isolate accepts any object except
type.
2026-10-12 18:56:41 +07:00
dotCooCoo 412e83c825 Render: the placed art of symbol instances and inline graphics is kept between frames
The renderer drew each symbol instance by copying its symbol's art,
staining the copy for a stained instance and moving every object in it
by the instance's transform, in every frame. The copies were new
nodes, so the caches the renderer keys by a node's address (cull
bounds, path geometry, glyph layouts, live-effect geometry) missed for
every object of every instance, and the copies stayed in those caches
until they were pruned. Stroke outlines missed as well, because their
cache uses an entry only when the entry holds the node being drawn.
The renderer copied and moved the art of inline graphics in type the
same way.

The renderer now keeps the art it places in a frame cache with a
64 MiB budget, under the address of the symbol's art, the transform,
whether strokes scale with it, and the instance's stain. When the art
is a group or a layer without an appearance of its own, the renderer
copies none of it in a frame whose instances and symbols did not
change, and the objects in it hit the caches keyed by their own
address (cull bounds and paths, glyph layouts, live-effect geometry).
The stroke-outline cache keys a stroke by its address in the
appearance. In the art placed for an unstained instance, each object
without a gradient fill or stroke shares its appearance with the same
object in the symbol's art. When a symbol has two or more unstained
instances, the renderer therefore still outlines the strokes of those
objects again for each instance in every frame. Moving or restaining
an instance, or editing its symbol's art, places that art again, and
the other instances keep theirs. Other art is placed again in every
frame, as before, and so is art that holds a symbol instance, type
with an inline graphic, an image, a placed document, a blend, an
envelope, a repeat or a raster effect. Art that holds type is placed
again when the fonts change.

vectorcraft-cli bench of a symbol of eight paths (three filled and
stroked, two only filled, three only stroked) shown by 2,000
unstained instances took 51.6 ms a frame on main and 32.3 ms with
this change, at 2880x1800 on 8 threads; the same paths in 2,000
groups took 26.0 ms. The peak working set of a bench run at that size
went from 150 MiB to 83 MiB. In the tests, a renderer that keeps
placed art draws the same pixels as a new renderer, and instances
draw the same pixels as groups of their placed art.
2026-10-12 18:56:39 +07:00
dotCooCoo a9a7a4cddf Fix #1257: a dragged object reuses its filter and shadow rasters
Each pointer move of a drag applies the move to the interaction's
snapshot, so the dragged object is a new node in every frame. The
renderer kept the rasters of an object's drop shadows, outer glows and
filtered content (Gaussian Blur, Feather and the Photoshop-style
filters) only for the same node, so every frame of a drag ran those
effects again. On main, a moved frame of texture_drag_perf (a CMYK
rounded rectangle at 190.07% and DPI 1.5 in an 1800 x 1500 view) took
112 to 197 ms in a release build.

The canvas now renders with RenderOptions::interacting while a drag or
a dialog's preview is in progress. When an object misses its cached
raster in such a frame, the renderer takes the raster an earlier frame
made for an object with the same id, slot and ink at the same zoom and
rotation, made by the same effects from content of the same size (to
within 0.000001 pt). It draws the object's content into a raster
placed as that one was and compares the pixels byte for byte (only the
alpha under a shadow or glow). For Feather it also compares the points
of the clip outline, to within 0.000001 pt. When they match, the old
raster is drawn at the new place, rounded to a whole pixel, as a pan
draws a cached raster. Content under De-Interlace never takes a moved
raster, and a raster made over moved rasters counts as moved itself.

A frame rendered without interacting never draws such a raster, or a
raster made over one: the frame after the drag runs the effects again
and draws what a fresh renderer draws. The canvas's cache key includes
whether an interaction is in progress. On the desktop, the canvas
sends the frame after an interaction to the render worker when the
frame shown drew moved rasters. Each cached raster keeps the content it
was made from, within the existing 512 MiB budget. The pixels kept
with a filtered raster take as many bytes as the raster, and the alpha
kept with a shadow or glow takes a quarter as many as the shadow.

texture_drag_perf now renders a drag's frames as the canvas does, then
the frame after the drag, and that frame once more with every raster
cached. A moved frame now takes 20 to 26 ms, close to the 18 to 25 ms
of the cached frame; the frame after the drag takes 110 to 189 ms.

Docs: docs/target-app-parity.md.
2026-10-12 18:56:38 +07:00
RealtaNua de777a1b09 Contributors: Nael-Nathanael's entry (GitHub profile name) 2026-10-12 18:56:36 +07:00
RealtaNuaandClaude Opus 5.5 4744c2f04b Fix #1373: --control without a valid port stops the desktop before it starts
--control took the next argument as its port and dropped it when it wasn't a number, so the app
started without a control server and `--control --automation-read-root DIR` opened DIR as a
document. automation_args::ControlArg now reads --control <port> or --control=<port> once and
requires a port from 1 to 65535; VECTORCRAFT_CONTROL_PORT must be one too, checked only when
--control isn't given so --control and --version win over a bad value. Errors go through the
same exit as the root flags (#1157).

Tests: unit tests in automation_args.rs; tests/automation_args.rs runs the binary for each bad
form and checks the exact message and a prompt failing exit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:56:36 +07:00
BFlat 155d921fe7 Merge pull request #1392 from storytold/fix/1264-ai-area-type
.ai import: area type shows its first line in a substituted font (follow-up to #1264)
2026-10-12 18:52:30 +07:00
bflatastic 0cf59a6cbd .ai import: area type shows its first line in a substituted font (follow-up to #1264)
Area type whose frame is too short for a line of the font it shows in here
drew nothing: a font that isn't available shows in the fallback font, whose
line may be taller than a frame made for one line of the file's font (overset
type isn't drawn). Where the file itself lays lines out in the frame, the
frame of the imported text object is made taller, from its top, by what its
first line needs, and the open result says how many frames were.
2026-10-12 18:52:09 +07:00
BFlat 46131bd091 Merge pull request #1391 from storytold/fix/1264-ai-labels
.ai import: type goes to its own text object across looks, hidden layers and artboards (follow-up to #1264)
2026-10-12 18:51:55 +07:00
BFlat b185fbbe45 Merge pull request #1390 from storytold/fix/1264-ai-gradient
.ai import: a gradient with stops in the middle runs its whole ramp (follow-up to #1264)
2026-10-12 18:51:49 +07:00
bflatastic 8e16c8529f .ai import: type of text objects with an appearance, on hidden layers and on later artboards goes where it belongs (follow-up to #1264)
- The type in a text object's drawn look (1 /FreeUndo) is a story of the
  undo-free text document, which counts its stories on its own. It was read
  as the story of that number in the text document, so a label could show
  another object's text in another object's frame. The object itself, which
  follows the look, is the type now.
- The page's type of a text object on a hidden layer stays with that object
  (hidden) instead of going to the nearest text object that shows.
- The type on the page of a later artboard is matched where the layers have
  that artboard, not by the first page's place.
2026-10-12 18:42:18 +07:00
bflatastic 8ce63aa1d1 .ai import: a gradient with stops in the middle runs its whole ramp (follow-up to #1264)
The editing data places a gradient with matrices after Bg: Xm for a linear
gradient's whole ramp and one Bm for each pair of stops next to each other.
The reader kept the last Bm, so a gradient of three stops drew only its last
part. It now uses Xm (or, without one, the span of the Bm's from the first
stop to the last), and a radial gradient's one Bm as before. The angle and
length are in the matrices, so Bg's own numbers are used only without them.

An object's own note of its gradient after it (Bb, Bg, BB with no path) no
longer becomes the fill of the next path.
2026-10-12 18:42:16 +07:00
BFlat 885a6e38cd Merge pull request #1381 from storytold/fix/1264-ai-placed
Fix #1264: placed (linked) files in a .ai file's editing data come in as linked images
2026-10-12 18:05:00 +07:00
BFlat ab50796a27 Merge pull request #1382 from storytold/fix/1367-dialog-refocus
Fix #1367: a dialog opened from a menu takes the keyboard focus every time
2026-10-12 18:02:07 +07:00
bflatastic 0765bc9bcd Fix #1367: a dialog opened from a menu takes the keyboard focus every time
A dialog opened by a click is drawn in the frame of that click, and in that
frame egui takes the focus from any field the click missed. The first time a
dialog's window shows, that frame only measures it, so its first field took
the focus a frame later and kept it; every later time the field took it in
the click's frame and lost it at once. The field now takes it the frame after
the click.
2026-10-12 17:48:09 +07:00
bflatastic 39c14abd35 Fix #1264: placed (linked) files in a .ai file's editing data come in as linked images
A .ai file with placed files opened from its PDF part (no guides, no sublayers,
no hidden objects), because the editing-data reader stopped at them.

- Lexer: a placed file's preview section is one token (it was skipped).
- Reader: %AI5_BeginPlace ... ~ becomes a linked image with the file's path,
  showing the preview the data keeps of it (premultiplied ARGB, capped at
  MAX_PIXELS, its rows checked against the bytes there), in its place: the
  matrix takes the file's pixels from the canvas, where they start at the point
  of art space's origin. A placed file without a preview, a path or a matrix
  still makes the file open from its PDF part.
- The open result lists one warning saying linked pictures show the
  low-resolution preview until their files are found (rows in the catalogs).
- Engine: a link nothing is known of whose image shows a preview is read where
  it is found as the document opens, into the image's bounds, and isn't
  reported as modified.
- Tests: lexer, reader (hand-written data), engine open with the link missing
  and found, fuzz tokens.
- Docs: file-format-parity, target-app-parity, gaps, ROADMAP.
2026-10-12 17:46:01 +07:00
BFlat 0a357c68a9 Merge pull request #1379 from storytold/fix/pdf-editing-data-bleed
PDF import: a PDF saved with its editing data opens on its artboard with its bleed
2026-10-12 17:29:01 +07:00
BFlat 57598c067d Merge pull request #1378 from storytold/docs/progress-1012e
Docs: ROADMAP 2026-10-12 adds #1239–#1374 (community PRs of the day)
2026-10-12 17:28:39 +07:00
BFlat fc0f732677 Merge pull request #1376 from storytold/fix/font-fallback-retry-after-rescan
Fonts: a character no font covered is looked for again after a font rescan
2026-10-12 16:48:05 +07:00
BFlat ef0c37349d Merge pull request #1375 from storytold/fix/artboard-flush-roundoff
Artboards: art flush with an artboard moves with it despite a rounding error
2026-10-12 16:47:55 +07:00
Brandon ThomasandClaude Opus 5.5 71174477cc Release prep: 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:41 -04:00
bflatastic 9d63926291 PDF import: a PDF saved with its editing data opens on its artboard with its bleed
A PDF-compatible .ai opens on its trim boxes, its editor's artboards,
with the bleed boxes round them as the document's bleed (#1349). A PDF
saved with its editing data (#1345) keeps that data under
AIPDFPrivateData keys, which has_illustrator_data didn't look for, so
such a PDF with a bleed still opened on its crop box: the artboard took
the bleed in and the editing data was refused as being for a different
page. has_illustrator_data now finds either key. has_private_data, which
also decides how a page is clipped, is unchanged.

Test: a_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it
(the .ai case, and the same hand-written file with AIPDFPrivateData).
2026-10-12 16:44:16 +07:00
bflatastic 2cf0452beb Docs: ROADMAP 2026-10-12 adds #1239, #1250, #1252, #1308, #1318, #1335, #1339, #1345–#1359 2026-10-12 16:44:16 +07:00
bflatasticandsanyamsingh001 4251e337c1 Fonts: a character no font covered is looked for again after a font rescan (#992)
A character missing from every installed font was remembered as missing for the whole
session, so installing a font that has it didn't help until a restart. A rescan of the
font folders now forgets those misses, and a search a rescan overtook doesn't record one.

Taken from #992 by sanyamsingh001, without its font file probe.

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
2026-10-12 16:43:41 +07:00
bflatasticandLuca Lorenz 8b96db8f0b Artboards: art flush with an artboard moves with it despite a rounding error (#1295)
A background drawn at the artboard's own position could sit 3e-14 pt outside it and
stay behind when the artboard moved, was copied or scaled its art. Art within a
millionth of a point of the artboard's edges now counts as inside it.

The fix and both tests are from #1295 by Luca Lorenz, without its change to art that
only partly overlaps the artboard.

Co-authored-by: Luca Lorenz <lucalorenzetti1993@gmail.com>
2026-10-12 16:43:41 +07:00
BFlat 8716fc8fbc Merge pull request #1374 from storytold/fix/1358-trace-panel-width
Part of #1358: the Image Trace section no longer widens the Properties panel
2026-10-12 16:42:01 +07:00
bflatastic 268a4452f9 Part of #1358: the Image Trace section no longer widens the Properties panel
Its View row asked for the row's whole width for its menu before its label was laid out, so
the row ran past the panel, the panel's content grew and the start of every label was cut off.
2026-10-12 16:32:43 +07:00
BFlat f97b24b388 Merge pull request #1371 from diegozpy/i18n/es-missing-rows
i18n(es): add 371 rows other catalogs already had
2026-10-12 16:09:44 +07:00
Diego Ospina a747daa346 i18n(es): add 371 rows other catalogs already had
Document presets, shortcut editor, print/PDF options, arrowheads,
harmony rules, Save for Web, graphic styles, file filters.

Models: Opus 5.5, Sol 6.1, DeepSeek V4 Flash
2026-10-12 16:07:18 +07:00
BFlat 2d619baaba Merge pull request #1363 from AyberkA0/fix/toolbar-icon-centering
Toolbar: centre the tool buttons inside the panel's border
2026-10-12 16:05:02 +07:00
BFlat f45ffdeb96 Merge pull request #1369 from callmeBalloch/fix/affinity-rectangle-corners
Part of #666: Affinity rectangles keep per-corner radii and straight corners
2026-10-12 16:04:52 +07:00
BFlat d6f1c9862d Merge pull request #1360 from dotCooCoo/feat/pdf-export-huge-text-offsets
PDF export: art beyond 1,000,000,000 pt is left out, so dev and test builds no longer abort
2026-10-12 16:03:44 +07:00
Ayberk A. 5a8682e45d Toolbar: centre the tool buttons inside the panel's border
The buttons were placed (WIDTH - 36) / 2 from the panel's content edge, but
the docked panel's content is narrower than WIDTH: the 1.5 pt frame stroke on
both sides and the 1 pt separator line egui reserves on the right leave 44 pt.
So the single column sat 6.5 pt from the left border and 1.5 pt from the right
(2.5 pt right of centre), and the double column's second button ran over the
right border. Centre each row on the panel's inside instead, and widen the
double column to 80 pt so its two buttons fit with 2 pt either side.
2026-10-12 15:41:47 +07:00
callmeBallochandClaude Opus 5.5 52e9b25371 Part of #666: Affinity rectangles keep per-corner radii and straight corners
Unlocked rectangle corners now each use their own radius and type (top left,
top right, bottom right, bottom left) instead of all following the first one,
and straight corners are cut across instead of rounded. patchy-shapes.af now
matches its embedded thumbnail (mean difference 1.91 -> 0.24) with no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:41:46 +07:00
dotCooCoo 5d5e7a1dd2 PDF export: art beyond 1,000,000,000 pt is left out, so dev and test builds no longer abort
In builds with overflow checks (`cargo run`, `cargo test` and
`cargo xtask ci`), PDF export aborted the process when a number it
wrote rounded to -2,147,483,648 as an f32. pdf-writer 0.15 writes
-2,147,483,648.0 as an integer, and `Limits::register_int` overflows
when it takes the absolute value. krilla 0.8.2's `Surface` then fails
an assertion in its `Drop` while that panic unwinds.
`svg_hostile_markup_never_panics` in `import_fuzz` hit it with type
shifted 2,147,483,649 pt down. Paths, gradients, dash offsets, images
and artboards that far out hit it too, and so does a group around a
stroke wide enough to reach that far. Release builds have no overflow
checks.

PDF export now leaves out art whose coordinates or transform entries
lie beyond 1,000,000,000 in its own space or in the content stream it
is drawn into, and glyph lines whose spacing lies beyond 1,000,000,000
thousandths of an em. That covers paths and clipping paths, glyph
lines, images, gradients, strokes whose width, caps or miter spikes
reach that far, and the art and marks of a page whose artboard lies
that far, which is written blank. A dash offset beyond the bound is
written as its remainder after whole periods of the pattern.

The type's transform is now pushed inside the form that marks a fill
or stroke of type to overprint. The exporter checks what that form
holds through the type's transform, as it checks the same fill or
stroke without overprint.
2026-10-12 15:41:45 +07:00
BFlat 42b355c21c Merge pull request #1364 from storytold/fix/1264-ai-guides
Part of #1264: an image on %_ lines of a .ai file's editing data is read as an image
2026-10-12 15:40:11 +07:00
BFlat a48ed5e253 Merge pull request #1192 from FredApps/fix/polar-grid-click-dialog-20261011
Add a Polar Grid canvas-click dialog
2026-10-12 15:39:37 +07:00
bflatastic aca313f10a Part of #1264: an image on %_ lines of a .ai file's editing data is read as an image
Its samples follow a %%BeginData comment that comes after the %_ prefix, where the lexer
took it for a comment later in a line and read the samples as tokens: the rest of the file
was misread ("U closes something it didn't open") and the file opened from its PDF part.
2026-10-12 15:30:11 +07:00
bflatastic 62bf8c862c Polar Grid dialog: Hungarian and Korean rows for its two labels 2026-10-12 15:30:11 +07:00
FredApps 1dc61af4d1 Add a Polar Grid canvas-click dialog
A click with the Polar Grid tool opens the shared shape dialog (width, height, concentric and
radial dividers), as the Rectangular Grid tool does, and keeps the divider counts as the tool's
options.
2026-10-12 15:30:11 +07:00
BFlat 3c3c249bc0 Merge pull request #1239 from zakariayacine/fix-corner-widget-direct
Live Corners: with Direct Selection, a click on a widget selects its corner
2026-10-12 15:27:23 +07:00
BFlat 1909827299 Merge pull request #1250 from haranishi/feat/issue-1212-ruler-origin
feat: 定規の原点を変更・リセットする #1212
2026-10-12 15:27:03 +07:00
BFlat cbea134e9c Merge pull request #1252 from haranishi/fix/issue-1227-artboard-from-object
fix: 図形のクリックからアートボードを作成する #1227
2026-10-12 15:26:46 +07:00
BFlat 8115d58c3f Merge pull request #1347 from dotCooCoo/feat/align-menu-follows-align-to
Align: Object › Align follows Align To, and the choice is kept across restarts
2026-10-12 15:26:31 +07:00
BFlat 617b78b35f Merge pull request #1308 from haranishi/fix/issue-1293-pathfinder-names
fix: Pathfinderの名前とSVG IDを保持する #1293
2026-10-12 15:26:20 +07:00
BFlat 5a09dfec81 Merge pull request #1335 from haranishi/fix/issue-1307-divide-open-lines
fix(pathfinder): 開いた切断線で図形を分割する (#1307)
2026-10-12 15:26:05 +07:00
BFlat 6944497076 Merge pull request #1339 from haranishi/fix/issue-1326-numeric-drag-selection
fix(ui): 数値欄のフォーカス時にドラッグ選択を保つ (#1326)
2026-10-12 15:25:53 +07:00
BFlat 82db346d52 Merge pull request #1318 from haranishi/fix/issue-1299-dxf-closed-splines
fix(cad): DXFの制御点SPLINEの閉鎖を読み込みに反映する (#1299)
2026-10-12 15:25:38 +07:00
zakariayacineandClaude Fable 5.1 84336342f3 Live Corners: with Direct Selection, a click on a widget selects its corner
Rounding a single corner is done by selecting that corner, then
dragging its widget: a drag on a path selected whole rounds every
corner with both tools, as before. What was missing is selecting the
corner on the widget itself. With the Direct Selection tool a plain
click on a corner widget (press and release, no drag) now selects that
corner, as a click on the corner's anchor does; Shift-click toggles it.
The widget is then the only one showing, and the next drag on it rounds
that corner alone.

Unchanged: Alt-click cycles the corner kind, the Selection tool's click
on a widget does nothing, corners picked by their anchors edit together.

Test: corners::tests::a_click_on_a_widget_with_direct_selection_selects_its_corner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:17:28 +07:00
bflatastic 23ec8d9e67 i18n: Hungarian and Korean rows for the ruler origin commands 2026-10-12 15:17:26 +07:00
Ikki Nishihara 29ecab8c48 feat: change and reset the ruler origin (#1212) 2026-10-12 15:17:24 +07:00
Ikki Nishihara 932c127323 fix: the Artboard tool creates an artboard from a clicked shape (#1227) 2026-10-12 15:17:12 +07:00
dotCooCoo 44685d8798 Align: Object › Align follows Align To, and the choice is kept across restarts
Object › Align ran object.align without to, so it aligned to the key
object while there was one and otherwise to the selection. With Align
to Artboard chosen it aligned two objects to the selection's bounds,
and it left a single selected object where it was, while the align
buttons of the Align panel, the Control bar and the Properties panel
move that object to the active artboard. The Align To choice was kept
in egui's temporary memory, so it went back to Align to Selection at
each launch, and ui.inspect did not report it.

The choice is now the UiState field align_to, which the desktop app
saves with its preferences and ui.inspect reports. A saved value other
than "artboard" reads as Selection, and the other preferences still
load. Object › Align now sends the to that the align buttons send: the
artboard when one object is selected, else the key object while there
is one, else the Align To choice. With anchor points selected it sends
its parameters as before, and the points align to their own bounds.
While one object is selected, the Align To row shows Artboard. Agents'
object.align calls without to keep the engine's default.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-12 15:17:06 +07:00
Ikki Nishihara c85570c8b2 fix: Pathfinder results keep the source's name and SVG id (#1293) 2026-10-12 15:17:01 +07:00
Ikki Nishihara 18e538ec94 fix(pathfinder): Divide splits shapes with an open cutting line (#1307) 2026-10-12 15:16:57 +07:00
Ikki Nishihara 6ab08dd407 fix(ui): numeric fields keep a drag selection on focus (#1326) 2026-10-12 15:16:55 +07:00
Ikki Nishihara 0d244440a9 fix(cad): a closed control-point DXF SPLINE is read as closed (#1299) 2026-10-12 15:16:43 +07:00
BFlat 55e0459e21 Merge pull request #1350 from aguo890/fix/394-black-on-screen
Part of #394: Appearance of Black › On Screen shows pure CMYK black as RGB black
2026-10-12 15:10:34 +07:00
BFlat 9c41d78787 Merge pull request #1354 from dotCooCoo/feat/direct-selection-pattern-tiles
Part of #394: art moved whole with direct-selected anchors follows Transform Pattern Tiles
2026-10-12 15:10:16 +07:00
BFlat bdbcf8e1c5 Merge pull request #1353 from osaidahmed/perf/linear-deselect-uneditable
perf: deselect hidden and locked objects in linear time
2026-10-12 15:09:58 +07:00
BFlat 7046c52520 Merge pull request #1355 from dotCooCoo/feat/moves-into-hidden-layers-deselect
Layers: art moved into a hidden or locked layer, or hidden by a data set, is deselected
2026-10-12 15:09:30 +07:00
BFlat 8a7bf7920b Merge pull request #1345 from dotCooCoo/feat/pdf-editing-data
Part of #868: PDFs saved with the reference app's editing data open from it, as .ai files do
2026-10-12 15:09:15 +07:00
BFlat 43c988f099 Merge pull request #1349 from aguo890/fix/1266-pdf-bleed
PDF/.ai import: a .ai opens on its artboard with its bleed round it (#1266)
2026-10-12 15:08:52 +07:00
BFlat 4f280f0e1a Merge pull request #1351 from aguo890/fix/1304-shape-builder-clip-gradient
PDF import: gradient-filled shapes open as one path, not a clipped rectangle (#1304)
2026-10-12 15:08:30 +07:00
Aaron GuoandClaude Opus 5.5 b29e271b4a Part of #394: Appearance of Black › On Screen shows pure CMYK black as RGB black
Preferences › Appearance of Black › On Screen was stored and never read.
Display All Blacks Accurately (the default) shows pure CMYK black, black
ink alone at 100%, through the working CMYK profile, a dark grey, as the
canvas always did. Display All Blacks as Rich Black now shows it on the
canvas as RGB black: fills, strokes, type, gradient stops, effect colours
and pattern tiles, and in CMYK documents drawn by ink planes a pixel
whose inks come out as black ink alone. Rich blacks, black tints and
placed images show the same either way; exports and printing are
unchanged (Printing / Exporting is a separate option).

The renderer gets RenderOptions::rich_black, painted with a new
Ink::RichBlack (screen colours with pure black as RGB black) and applied
by ink::compose. The canvas sets it from blackOnScreen and keeps it in
its cache key, so changing the preference redraws.

A headless frame test reads the canvas raster: K100 shows its profile
grey by default, RGB black with rich, a rich black unchanged, and grey
again with accurate. A render test covers the opaque and ink-plane paths
and checks that rich blacks, tints and images don't change.

Docs: docs/mcp.md (blackOnScreen), docs/target-app-parity.md,
docs/gaps.md, docs/ui-parity.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:50:00 +07:00
dotCooCoo ae061eacfb Part of #394: art moved whole with direct-selected anchors follows Transform Pattern Tiles
With anchors of one path direct-selected and another object selected
whole, the arrow keys (object.nudge), path.moveAnchors and a Direct
Selection drag of the selected anchors moved that object without its
pattern tiles, whatever General › Transform Pattern Tiles was set to.
When the same object was selected on its own, the arrow keys and a
Direct Selection drag followed the preference. path.moveAnchors left
the tiles in place in that case too.

path.moveAnchors now follows the preference for the objects it moves
whole, as the transforms, Align and Distribute do. path.moveAnchors
and object.nudge also take a `patterns` param for one call. When those
objects use a pattern, the journal records the choice, and a recorded
action plays back with it. Partly selected paths still move only their
selected anchors.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
2026-10-12 14:50:00 +07:00
Omar Saidahmed 497172bc97 deselect hidden and locked objects in linear time 2026-10-12 14:50:00 +07:00
dotCooCoo 307a5a6298 Layers: art moved into a hidden or locked layer, or hidden by a data set, is deselected
These commands moved selected objects into a hidden or locked layer,
or hid them, and left them selected, with the key object still set
when it was one of them:

- a row dragged into a hidden layer or group (layer.move);
- node.move into a hidden or locked layer or group;
- Merge Selected into a hidden or locked layer;
- Flatten Artwork into a locked layer;
- Collect in New Layer, when the topmost row it collects is in a
  hidden or locked layer;
- applying a data set that hides bound objects (dataset.select,
  dataset.next, dataset.prev).

They now call Selection::deselect_uneditable after the change, as
layer.setProps, layer.hideOthers and layer.lockOthers do. Every
selected object left hidden or locked is deselected, and the key
object is cleared when it is one of them. Undo restores the objects
and selects them again with the key object. A row drag into a hidden
layer and node.move into a hidden or locked layer still succeed. The
params text of layer.move, node.move, layer.merge, layer.flatten,
layer.collectInNew and dataset.select describes the deselection.

The Layers panel section of docs/mcp.md said that node.move has the
same rules as layer.move; it now says that node.move also moves a row
into a locked layer or group, which layer.move refuses.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-12 14:50:00 +07:00
dotCooCoo 1e76e389d2 Part of #868: PDFs saved with the reference app's editing data open from it, as .ai files do
A PDF saved with the reference app's editing data keeps that data in
streams named AIPDFPrivateData1, AIPDFPrivateData2, ..., where a .ai
file uses AIPrivateData1, AIPrivateData2, .... illustrator_data read
only the .ai names, so such a PDF opened like any other PDF: plain
groups opened ungrouped, and hidden objects and guides were missing. The
import gave no warning that the PDF's editing data went unread.

illustrator_data now reads the AIPDFPrivateData streams when the file
has no AIPrivateData1, and the PDF opens from its editing data as a .ai
file does. editingData: false still opens it like any other PDF.
has_private_data still looks for AIPrivateData keys only.

layered_ai also returns whether it read the layers. When it did, art
reaching past a PDF's page opens whole, and through_editing_data drops
the PDF importer's note that such art was clipped (PAST_PAGE_NOTE, now
public) as it drops the off-artboard note. When it didn't, both notes
stay. On main, a .ai whose editing data can't be decoded or has no
layers opened from its PDF part without the off-artboard note.

testkit::ai::pdf writes the .ai file of testkit::ai::ai with the PDF
key names. The editing-data tests open it as a PDF, and the import fuzz
test opens the damaged sample as a PDF too.

Docs: the document.open params, docs/mcp.md,
docs/file-format-parity.md, docs/gaps.md (G9: the export direction of
#868 remains), one evidence line in docs/target-app-parity.md, and #868
moved from layer structure to editing data in the File formats row of
docs/target-app-parity.md and in docs/roadmap.md.
2026-10-12 14:50:00 +07:00
Aaron GuoandClaude Opus 5.5 a99b1200ff PDF/.ai import: a .ai opens on its artboard with its bleed round it (#1266)
A PDF-compatible .ai with a bleed writes its PDF part's page as the
artboard grown by the bleed: the crop box takes the bleed in, the trim
box is the artboard and the bleed box the bleed. The importer made the
crop box the artboard, so the bleed opened as more white page with no
bleed guide; and since the editing data's artboard no longer matched
that page, its layers weren't read either ("its editing data is for a
different page").

Opening a PDF-compatible .ai with the crop box (the default) now takes
its trim boxes as the artboards. Any import on the trim boxes (Crop To
› Trim, for a PDF too) sets the document's bleed to how far the bleed
boxes reach past them on each side (ISO 32000-1, 14.11.2), kept to
0–72 pt, and the document read from the editing data keeps it. Art in
the bleed shows outside the artboard, within the bleed guide. Plain
PDFs still open on their crop box by default, and Place is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:50:00 +07:00
Omar Saidahmed 88e9381de6 move selection tests into their own module 2026-10-12 14:50:00 +07:00
Aaron Guo 3428a224bb Contributors: add aguo890 (Aaron Guo) 2026-10-12 14:49:59 +07:00
Aaron GuoandClaude Opus 5.5 563ff62aa0 PDF import: gradient-filled shapes open as one path, not a clipped rectangle (#1304)
A PDF, and the PDF part of a PDF-compatible .ai, writes a shape filled
with a gradient as a clip followed by a shading fill: the shape, W n,
then sh, which fills the clip's bounds. The importer kept that as a
clip group of the shape (a clipping path) and a rectangle the size of
its bounds filled with the gradient. The Shape Builder skips clipping
paths, so it built its pieces from the rectangles: two overlapping
gradient-filled diamonds gave three rectangles. The Fill swatch showed
? for such a selection, since it held groups.

A clip whose only content is one rectangle covering the clip, filled
with a gradient placed on the page and nothing else, now comes in as
one path: the clip's outline, its subpaths closed, with the
rectangle's fill, opacity, blend mode and mask. It draws as before and
edits like a gradient-filled path drawn in the app. Clips holding
anything else (a flat colour, several objects, a gradient that doesn't
cover the clip, a shading that doesn't extend past its ends) stay clip
groups.

Tests: a diamond clipping a shading imports as one closed path of four
anchors with the linear gradient where it was, and a gradient covering
only part of the diamond or a flat colour stay clipped. Two overlapping
diamonds in a hand-written PDF, clicked on their overlap with the Shape
Builder, give what is left of each (9000 pt² each) and the overlap
diamond (3000 pt²), each with its gradient; main gave three rectangles
of 12000 pt².

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:49:59 +07:00
BFlat 21c580a934 Merge pull request #1352 from Johau91/feat/korean-ui
G16: Korean interface (ko)
2026-10-12 14:45:08 +07:00
Johau91andClaude Opus 5.5 976d1bb428 G16: Korean interface (ko)
Add a Korean catalog (crates/ui-egui/src/i18n/ko.tsv, 4,151 rows) covering
every menu string, every tl! literal and the status and error messages, and
register `ko` in i18n::LANGUAGES (plural_none; every ko-* locale resolves).

- tests: korean_reads_as_korean, ko locale tags, ko in KEEPS_MENU_NAMES and
  COMPLETE_MESSAGES; is_cjk covers Hangul so the bundled-font glyph check
  leaves Hangul to installed fonts, as it does CJK without craft-fonts.
- Hangul is drawn with an installed system font on the desktop; the web
  build still needs a Korean UI face in craft-fonts.
- docs: localization-parity (Korean row, 12-language score 54% -> 63%,
  60-100 h), ROADMAP, README, development.md, gaps.md G16,
  target-app-parity.

Clean-room: written from the meaning of the English strings in ordinary
Korean vocabulary; no other product's Korean localisation was used.
AI-drafted; native-speaker review welcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:44:39 +07:00
BFlat 96a783e716 Merge pull request #1324 from skylartaylor/codex/craft-print-adoption
Adopt craft-print for desktop printing, preserving Windows 7
2026-10-12 14:35:18 +07:00
skylar taylor-barrick 8cb5fa0607 Print: clarify automation results and discovery failures 2026-10-12 14:20:48 +07:00
skylar taylor-barrick e74c2f113c Print: adopt craft-print while retaining Windows 7 support 2026-10-12 14:20:48 +07:00
BFlat 5432821480 Merge pull request #1359 from storytold/fix/1286-ai-alpha-mask
Part of #1286: constant alpha masks are drawn white
2026-10-12 14:17:45 +07:00
bflatastic 934d83405c Part of #1286: constant alpha masks are drawn white, so the page shows where soft masks are read by luminosity
A CMYK page with transparency is drawn through an alpha soft mask that covers the page (a
non-isolated group). Its cover was black: opaque as alpha, but nothing shows in a reader that
takes the mask's luminosity. A white cover is the same mask either way.
2026-10-12 14:00:37 +07:00
BFlat fecea5390d Merge pull request #1343 from storytold/docs/progress-1012d
Docs: ROADMAP 2026-10-12 adds #1245, #1336, #1337, #1341
2026-10-12 11:29:00 +07:00
bflatastic 0a538ef40d Docs: ROADMAP 2026-10-12 adds #1245, #1336, #1337 and #1341 2026-10-12 11:28:49 +07:00
BFlat b586af83c2 Merge pull request #1341 from dotCooCoo/feat/eps-quadratic-tiling-registration
EPS: the PostScript reader reads many tiling patterns, spot inks and shadings in linear time
2026-10-12 11:28:00 +07:00
dotCooCoo be9d05b274 EPS: the PostScript reader reads many tiling patterns, spot inks and shadings in linear time
Setting a tiling pattern went through every pattern made so far to
find one with the same PaintProc, cell and color, and went through the
document's patterns again to name a new one. Each spot ink was looked
up in the list of every ink used. When the reading ended, each ink and
each pattern became a swatch after a search of all the swatches, and
the second object of each clipping group of two was looked up in the
list of every shading painted. For a file that makes n of any of these,
the searches did work that grows with n squared: a dev-profile build of
vectorcraft-cli info took 99 user CPU seconds on a file that makes
160,000 tiling patterns, 35 on one that uses 160,000 spot inks, and 3.2
on one that paints 160,000 shadings through clips.

The patterns are now kept in a hash map, under a hash of the
PaintProc's storage, the cell, and the color, tint and swatch that an
uncolored pattern is set in. A lookup compares the patterns under the
same hash, with the comparison it made before. The inks are kept in a
map by name, and the shadings are kept in a set. The swatch names go
in a set while the swatches are made. The reader's document gets its
patterns only from tiling, so the name search always ended at Pattern
n+1 after n patterns, and that name is now made directly. A pattern
whose cell has a NaN corner never compared equal to another, and it is
no longer kept for lookups. The three files now read in 1.3, 0.6 and
0.9 user CPU seconds, and the documents read are the same as before.

Tests: a program that makes 200,000 tiling patterns and uses 200,001
spot inks reads within 20 seconds, with the patterns named in order;
a pattern made again from its prototype, and an uncolored pattern set
again in a color it was set in, are the same pattern.
2026-10-12 11:25:31 +07:00
BFlat 04a305367e Merge pull request #1245 from arhammusheer/perf/memory
perf: bound memory on large documents (#1244)
2026-10-12 11:20:05 +07:00
bflatastic d63c57d1e1 #1244: Hungarian rows for the new messages in the catalog's own voice (mentsd, jelentsd) 2026-10-12 11:03:03 +07:00
bflatastic 6fcdb421d1 #1244: the status bar stops saying a file is opening once it has opened 2026-10-12 11:03:01 +07:00
bflatastic e2f0aed1bf #1244: test that a native save failing partway leaves the file as it was 2026-10-12 11:03:01 +07:00
bflatastic ecf361c9d7 #1244: reserve at most 64 MB for a file's entry before its data is read
The entry's length comes from the file: a damaged or hostile one claiming 1 GB had it reserved
before a byte was unpacked.
2026-10-12 11:03:01 +07:00
bflatastic ecb4be19ba #1244: a crash between a recovery copy's document and its details keeps every image
The details name the copy's images, so the new document written before them paired with the old
details lost the images placed since the last copy (and a first copy lost all of them). Until the
new document is in, the details name the old and the new images.
2026-10-12 11:03:01 +07:00
arhammusheerandClaude Opus 5.5 023c8e3edd #1244: streaming open and save, per-section limits, recovery that keeps images apart, background open
Save serializes JSON straight into the compressor and writes images from their Arc into the
file; open reads each image into its own buffer, and NodeKind reads its tag first instead of
buffering every node. Already-compressed images are stored rather than deflated again in the
same gzip stream. Limits apply per section, so any file VectorCraft writes opens again. Data
Recovery writes each image once, content-addressed, instead of every interval. Native files open
on a background thread. Files stay readable by main both ways; JSON-only saves are identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:33 +07:00
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00
arhammusheerandClaude Opus 5.5 c4214be9d7 #1244: render caches bounded by bytes, evicting the least recently used instead of clearing
Text geometry, clips, clip paints, effect art, brushes, live effects, pattern tiles and freeform
grids were capped by entry count and cleared wholesale on overflow, so a document past the cap
(e.g. more than 4,096 type objects) recomputed every entry on every frame. A FrameCache keeps
per-entry byte sizes and last-used frames, drops the least recently used entries of earlier
frames, and sweeps stale ones. Text geometry keeps its glyph outlines once instead of twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:17 +07:00
arhammusheerandClaude Opus 5.5 454f1737a3 #1244: perf --deep measures four embedded photos drawn by three renderers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:17 +07:00
arhammusheerandClaude Opus 5.5 9139883075 #1244: navigator and layer thumbnails render off the UI thread and wait for drags to end
The navigator re-rendered the whole document on the UI thread on every revision, including
each drag step, and layer thumbnails re-rendered whole layers the same way. Both now keep the
last image while an interaction runs, render on a background worker once the document settles
(synchronously on wasm and in tests), and turn on image mips. Thumbnails are keyed by node id
with a Weak, not by a reusable address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:17 +07:00
arhammusheerandClaude Opus 5.5 bbbafdef6b #1244: undo history bounded by bytes, without lookup tables or repeated selections in its steps
History was limited by step count only; a whole-selection move of 50k paths keeps ~40 MB per
step. Steps now carry a size estimate (only unshared subtrees are visited) and the oldest are
dropped beyond a byte budget, keeping the last 8. Documents entering history drop their id
index, selections are shared between steps, the command journal is bounded (held while an
Action records), and document resources are copy-on-write. Saved files are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:17 +07:00
arhammusheerandClaude Opus 5.5 fe8a6983c5 #1244: one shared, byte-budgeted store for decoded images; render caches that sweep and stay bounded
Decoded images were kept per Renderer at full resolution and never evicted, so the canvas,
render worker, thumbnails and panels each held their own copy (96 MB per 24 MP photo).
They now live once in a process-wide LRU store keyed by content, with optional mip levels
(RenderOptions::image_mips, off by default so exports stay identical). Thumbnail renders
now sweep their caches, and shadow/effect rasters share a 512 MiB budget and match by Arc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:17 +07:00
BFlat c373fc233c Merge pull request #1337 from dotCooCoo/feat/no-op-undo-pathfinder
Path: Simplify and Add Anchor Points record no undo step when they change no path
2026-10-12 10:32:34 +07:00
dotCooCoo 76ca890bb4 Path: Simplify and Add Anchor Points record no undo step when they change no path
Simplify and Add Anchor Points edited the document whenever the
selection held a path, so a call that changed no path still recorded
an undo step, emptied Redo, marked the document modified and turned
the selected live shapes into plain paths. OK in the Simplify dialog
did the same. Simplify at its default options leaves a rectangle, a
star, a line and a path with one anchor as they are, and Add Anchor
Points leaves a path with one anchor as it is.

Both commands go through edit_paths, which now computes the new paths
before it edits the document. When no path changes, the command
returns without editing the document, and the reply
({ids, before, after}) has the same form and counts as before. When
some paths change, the command replaces only those paths and records
one undo step, so a path that Simplify leaves as it is keeps its live
shape.

One line in the evidence list of docs/target-app-parity.md names the
commands.
2026-10-12 10:23:17 +07:00
BFlat 022237ce02 Merge pull request #1336 from dotCooCoo/feat/no-op-undo-opacity-masks
Transparency: opacity mask options that change nothing record no undo step
2026-10-12 10:12:59 +07:00
dotCooCoo 8b6bfe6ed7 Transparency: opacity mask options that change nothing record no undo step
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options
(transparency.setOpacityMask) edited the document whenever a target
had an opacity mask, even when every mask already had the flags the
call set. Such a call recorded an undo step, emptied Redo and marked
the document modified.

When every mask a call acts on has those flags already, the call now
returns without editing the document, whether it acts on the
selection, on id or ids, or on the object whose mask is being edited.
The reply is still null, and the journal still records the call. A
call that changes a flag on any of the masks is still one undo step.

One line in the evidence list of docs/target-app-parity.md names the
commands.
2026-10-12 10:02:03 +07:00
BFlat f5f9408aa9 Merge pull request #1333 from storytold/docs/progress-1012c
Docs: ROADMAP 2026-10-12 adds #1325, #1327, #1329–#1332
2026-10-12 09:55:24 +07:00
bflatastic 17cff377a8 Docs: ROADMAP 2026-10-12 adds #1325, #1327 and #1329–#1332 2026-10-12 09:55:12 +07:00
BFlat 3c5d097458 Merge pull request #1332 from dotCooCoo/feat/eps-setdash-cap
EPS: loops under a long dash array no longer hang the import
2026-10-12 09:54:10 +07:00
BFlat ee8bd41ce0 Merge pull request #1331 from dotCooCoo/feat/no-op-undo-paint-items
Paint: fill, stroke, gradient and Appearance panel commands that change nothing record no undo step
2026-10-12 09:51:32 +07:00
BFlat 9d14654260 Merge pull request #1329 from dotCooCoo/feat/eps-uncounted-per-call-path-work
EPS: huge arcs, fine dashes and rectangle arrays in a loop no longer hang the import
2026-10-12 09:51:14 +07:00
BFlat 62ebd15cba Merge pull request #1330 from dotCooCoo/feat/eps-shading-function-fanout
EPS: shading functions in shared arrays no longer hang the import
2026-10-12 09:50:55 +07:00
dotCooCoo ae27cf6c2b EPS: loops under a long dash array no longer hang the import
setdash took a dash array of any length, and the graphics state kept
a copy of it. gsave, gstate, currentgstate and setgstate copied the
whole pattern with the state, currentdash made an array of it, and
each stroke and strokepath copied and scanned it. Each of these
counted as one operation against the operation budget. Under a
pattern of 1,000,000 entries, a pass of gsave grestore took 0.38 ms
of user CPU time, and a pass of strokepath on a line took 2.05 ms.
Loops of the two would reach the budget after about 1.6 and 2.8
hours.

setdash now raises limitcheck for a pattern of more than 65,535
entries, the most elements an array has in a typical PostScript
implementation (PostScript Language Reference, third edition,
Table B.1). Saved and copied graphics states share the pattern.
setdash and each stroke count each entry they read or copy as one
operation, and currentdash counts the array it makes against the
memory budget. Under a pattern of 65,535 entries, loops of setdash,
currentdash, stroke and strokepath now run out of the operation
budget, or the memory budget for currentdash, after 0.08 to 0.31
seconds of user CPU time.

Tests: a pattern of 65,535 entries is set, and a pattern of 65,536
entries stops the reading with limitcheck. Under a pattern of 65,535
entries, 4,000,000 passes of gsave grestore and 2,000,000 passes of
gstate currentgstate setgstate finish, and loops of setdash,
currentdash and strokepath run out of a budget. The test of these
loops reads each program on a thread of its own and fails when no
result arrives within 20 seconds.
2026-10-12 09:43:15 +07:00
dotCooCoo 9cbab1383b Paint: fill, stroke, gradient and Appearance panel commands that change nothing record no undo step
These commands edited the document even when nothing changed, so the
call recorded an undo step, emptied Redo and marked the document
modified:

- Fill, Stroke, None, Apply Last Color and Apply Last Gradient with the
  paint the objects have already, also on characters selected with the
  Type tool.
- stroke.set and stroke.setAdvanced with the options the strokes have
  already.
- paint.editGradient, paint.setGradientGeom and the freeform point
  commands when the gradient comes out as it was.
- Invert and Complement on the Appearance panel's selected row when no
  color changes.
- Swap Fill and Stroke and Default Fill and Stroke when every object
  comes out as it was.
- appearance.setItem with the values the item has or an index no
  object has, and transparency.set with an item and the values that
  item has; appearance.removeItem with indices past every stack;
  appearance.moveItem, Reduce to Basic Appearance, Clear Appearance,
  the Eyedropper (appearance.copyFrom) and appearance.transfer when
  every object comes out as it was.

These commands now return without editing the document. The replies
have the same form as before. On a graph's series or labels, a fill or
stroke that the art has already still records one undo step when the
graph does not keep that paint for them (for example after the
Eyedropper painted the art), and the graph then keeps it.

The Eyedropper test whose second copy changed nothing gives its target
a green fill first, which the copy turns red again.

One line in the evidence list of docs/target-app-parity.md names these
commands, apart from Invert and Complement.
2026-10-12 09:43:15 +07:00
dotCooCoo d0bb09c16f EPS: huge arcs, fine dashes and rectangle arrays in a loop no longer hang the import
The operation budget counted each operator once, however long a path
it made or read. arc and arct with a huge radius make up to about a
million curve segments, strokepath makes a piece for each dash, and
rectfill makes five path elements for each rectangle in an array.
pathbbox, clip, clippath, shfill, pathforall, gstate, currentgstate
and setgstate read or copy the whole current path or clip path, and
in an encoded user path a byte of the operator string can repeat the
next operator up to 223 times. In a loop, each pass counted a few
operations. vectorcraft-cli info was still reading each of 15 files
that run one of these in "loop" when "timeout 300" stopped it, after
28 to 128 seconds of user CPU time. With a radius of 50 instead of
2.9e30, the loop of arc ran out of the budget after 6.9 seconds of
user CPU time.

Each of these operators now counts the path elements it makes, reads
or copies as one operation each. Making or reading an element took up
to about as long as an operation in a loop of pop, and copying one
took less. strokepath also counts each entry of the dash pattern in
each period of the path. An encoded user path counts each entry of its
data array, each byte of its operator string and each operator it
runs. The 15 loops now end with "the program runs too long" after 0.06
to 2.35 seconds of user CPU time, and the loop of arc with a radius of
50 ends after 1.9 seconds of user CPU time.

Tests: a loop of each of these operators runs out of the operation
budget. The loops cover arc and arct with a huge radius, strokepath
with a fine dash and with dashes of length zero, rectfill with 2,000
rectangles, the eight operators that read or copy a path of 10,001
elements, and uappend on three encoded user paths. The tests read each
program on a thread of its own and fail when no result arrives within
20 seconds.
2026-10-12 09:43:15 +07:00
dotCooCoo 4a27d897ed EPS: shading functions in shared arrays no longer hang the import
A shading's Function can be an array of functions. eval evaluated every
entry of an array and the arrays inside it, with only a depth limit. An
array of 100 entries that all refer to the same array one level down,
nested that way a few levels deep, is small in memory, but each
evaluation visited about 100^levels functions, and a gradient samples
such a function 33 times. vectorcraft-cli info on a file with four
levels of 100 shared entries had used 90 CPU seconds, with a 12 MiB
resident set, when "timeout 120" stopped it. eval also counted nothing
against the operation budget: a loop that paints an axial or radial
shading counted a few operations a pass, however many functions each
pass evaluated. On a file that paints a gradient of one Type 2 function
with N 2 in "{ sh shfill } loop", vectorcraft-cli info ran for
209 CPU seconds, and its resident set reached 4 GB before the program
ran out of the operation budget.

eval now counts the functions one evaluation visits, as linear_points
counts its nodes, and raises limitcheck past 257. The PostScript
Language Reference defines a shading's function as one function
dictionary or an array of one for each color component. The reader
allows 32 components, and the depth limit allows each entry a chain of
8 functions. An array with more entries than the visits left raises
limitcheck before it is copied. Each function visited counts 8
operations against the budget: one visit took as long as 6 to 10
operations, measured with loops of pop and of a name lookup.

Tests: a shading over four levels of 100 shared entries raises
limitcheck, and the square drawn before it stays; a shading over one
array of 256 entries is drawn as a gradient, and one over 257 entries
raises limitcheck; a program that paints 2000 shadings over the
256-entry array runs out of the operation budget. The test reads each
program but the last on a thread of its own and fails when no result
arrives within 20 seconds.
2026-10-12 09:43:15 +07:00
BFlat 2c5262c5dd Merge pull request #1327 from webdevtodayjason/fix/plugin-dialog-int
Plug-ins: whole-number fields keep values past 2^53 exactly as typed
2026-10-12 09:33:29 +07:00
bflatastic 4412a4ea97 Plug-ins: share the whole-number field test harness 2026-10-12 09:21:30 +07:00
webdevtodayjason 31ae1eba0b Plug-ins: whole-number fields keep values past 2^53 exactly as typed 2026-10-12 09:21:04 +07:00
BFlat 2b7d5f964f Merge pull request #1325 from dotCooCoo/feat/select-menu-selectable-only
Select: Select All skips hidden isolated groups, and menu commands stay within what it selects
2026-10-12 09:17:12 +07:00
dotCooCoo d3be78000e Select: Select All skips hidden isolated groups, and menu commands stay within what it selects
Select All, All on Active Artboard and Inverse select from selectable:
the visible, unlocked objects on visible, unlocked, non-template
layers and sublayers, or in isolation mode the visible, unlocked
objects in the isolated group or layer and in its visible, unlocked,
non-template sublayers. Other Select menu commands did not use it:

- Clipping Masks, All Text Objects, Stray Points and Open Paths
  selected hidden and locked objects and the objects in hidden or
  locked groups and sublayers.
- Point Text Objects, Area Text Objects, Brush Strokes, Bristle Brush
  Strokes and the Same commands for symbols, fonts and text colors
  checked an object's own flags and its layer's, but not those of the
  groups and sublayers between them.
- Next Object Above and Below selected the object beside the selected
  one even when it was hidden, locked or a sublayer.
- A saved selection picked from the Select menu selected again the
  objects hidden or locked since it was saved.
- Direction Handles direct-selected the hidden and locked paths in a
  selected group.
- In isolation mode, the commands in the first two bullets, the other
  Same commands, All on Same Layers and a recalled saved selection
  selected art outside the isolated group or layer. Next Object Above
  and Below and Direction Handles did so only when the selection
  already held something that was not inside the isolated group or
  layer.

In isolation mode selectable checked the flags of the isolated group's
or layer's objects and sublayers, but not those of the group or layer
itself or of the groups and layers it is in. Select All, All on Active
Artboard and Inverse still selected the isolated group's or layer's
objects after it, or a group or layer it is in, was hidden or locked.

In isolation mode selectable now returns nothing while the isolated
group or layer, or a group or layer it is in, is hidden, locked or a
template layer. walk_selectable visits the objects selectable returns
and the objects inside them, leaving out hidden and locked objects
with what they hold. It visits nothing in a hidden, locked or template
layer, sublayer or group. The commands in the bullets above now select
from it. All on Same Layers takes the objects on the selected objects'
layers that selectable returns. Next Object Above and Below pass over
the objects walk_selectable leaves out, and leave the selection as it
is when none is left in that direction.

The params texts of Direction Handles and Recall Selection,
docs/mcp.md (Object selection, Saved selections) and one line in the
evidence list of docs/target-app-parity.md describe the change.
2026-10-12 09:05:14 +07:00
BFlat b94c13bfbb Merge pull request #1323 from storytold/docs/progress-1012b
Docs: merge duplicated table rows, record the rest of 2026-10-12
2026-10-12 08:58:43 +07:00
bflatastic 15de47b850 Docs: merge duplicated table rows and record the rest of 2026-10-12
Overnight doc conflicts were resolved by keeping both sides, which left two or three copies of
some milestone and parity rows (M1–M3, M5–M7, M14; six area rows; one type row). Each is now one
row with every note. The 2026-10-12 progress row takes the 30 PRs landed since and the Film
Grain row that #1282 added separately.
2026-10-12 08:58:27 +07:00
BFlat 62e6603c30 Merge pull request #1320 from rollroyces/fix/vectorcraft-1299-dxf-closed-spline-bit
fix(cad): set the closed bit on DXF SPLINE entities so closed curves import into CAM (#1299)
2026-10-12 08:53:50 +07:00
Royce 851edd530f fix(cad): set the closed bit on DXF SPLINE entities so closed curves import into CAM (#1299)
A subpath that geometrically closes (e.g. an ellipse outline) was always emitted
with SPLINE group 70 = 8 (planar only). Autodesk's DXF reference defines bit 0
of group 70 as 'closed', and CAM software such as VCarve relies on it to treat
the cut as a pocket or profile rather than asking the operator to join endpoints.

Now `self.e.int(70, if sub.closed { 8 | 1 } else { 8 })`.

Test: extend lines_are_lwpolylines_curves_splines_and_fills_hatches to assert
the existing closed ellipse's SPLINE group 70 is '9' (1 closed + 8 planar).
2026-10-12 08:53:36 +07:00
BFlat 9792ecd6e8 Merge pull request #1321 from dylanmtaylor/fix/ci-node-20-deprecation
ci: upgrade actions to Node 24 to fix deprecation warnings
2026-10-12 08:47:54 +07:00
BFlat f0e1de92be Merge pull request #1316 from dotCooCoo/feat/no-op-undo-type-runs
Type: Change Case, Smart Punctuation and other commands that change nothing record no undo step
2026-10-12 08:45:34 +07:00
dotCooCoo a24269eb15 Type: Change Case, Smart Punctuation and other commands that change nothing record no undo step
These commands edited the document even when nothing changed, so the
call recorded an undo step, emptied Redo and marked the document
modified:

- Change Case to the case the text has, Smart Punctuation on type with
  nothing left to change or on a selection with no type in it, and
  Find and Replace when every match is replaced with the text it is.
  All three rewrite the text runs through edit_runs, which now edits
  only when a run changes.
- Convert To Area Type with no point type among the objects, and
  Convert To Point Type with no area type among them.
- Type on a Path Options given the values the type has, without Flip:
  OK in the dialog with nothing changed, or a Type on a Path effect
  the type has.
- Fill with Placeholder Text on type that holds its placeholder text
  already.
- type.insert with an empty text on type that ends with a run of
  text.

These commands now return without editing the document. The replies
have the same form as before. A call that changes something still
records one undo step.

One line in the evidence list of docs/target-app-parity.md names every
command above except type.insert.
2026-10-12 08:45:22 +07:00
BFlat 256cd1f85a Merge pull request #1315 from fabian-co/pr/trace-oversized-image
Image Trace: an image over 64 megapixels is traced scaled down
2026-10-12 08:45:12 +07:00
Dylan M. Taylor c28a9b5dd2 ci: upgrade actions to Node 24 to fix deprecation warnings 2026-10-11 21:42:27 -04:00
bflatastic ae5fa87d9d Image Trace: an oversized image decodes within the allocation its 192 megapixel limit needs (512 MB on the web) 2026-10-12 08:23:19 +07:00
Fabian GrajalesandClaude Opus 5.5 e1fe2e388b Image Trace: an image over 64 megapixels is traced scaled down
An image larger than the tracer's 64 megapixels was refused. It is now
decoded and box-filtered down to the largest size of its aspect within
64 megapixels, and the traced paths are mapped back to the image's own
size. Images over 192 megapixels are still refused from their header,
before decoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbTizgRsDpFPjtBgZAvfrZ
2026-10-12 08:22:39 +07:00
BFlat 6aa2b9b0fb Merge pull request #1313 from itrycodinganditsbad/fix-1274
Snap to Point: drag a selection by the anchor pressed near (#1274)
2026-10-12 08:20:33 +07:00
BFlat 9d8d6062e7 Merge pull request #1311 from dotCooCoo/feat/eps-closepath-unbounded-path
EPS: closepath no longer grows the path past its limit
2026-10-12 08:19:43 +07:00
dotCooCoo db8b9bb04c EPS: closepath no longer grows the path past its limit
moveto, lineto and curveto check that the current path holds fewer
than 1,048,576 elements before they add one, and they stop the reading
with "a path has too many points" when it is full. closepath added its
element without that check. In an encoded user path, a byte of 255
runs the next operator 223 times. vectorcraft-cli info on a 622-byte
file with one uappend that runs closepath 11,150,000 times peaked at a
memory footprint of 601.9 MiB and reported "the file draws nothing".
The string operator makes strings of up to 4 MiB, and one uappend of
an operator string that long would add about 468 million elements of
56 bytes, about 24 GiB.

closepath now makes the same check before it adds its element, in the
closepath operator and in the ClosePath arm of append_user. On the
file above, the reading now stops with "a path has too many points",
and vectorcraft-cli info peaks at 62.0 MiB. A path below the limit
closes as before.

Tests: a moveto followed by 1,100,000 closepaths, and a uappend that
runs closepath 1,059,250 times, are each read between two squares.
Each reading stops with "a path has too many points" and keeps only
the first square.
2026-10-12 08:19:31 +07:00
BFlat c117bc6e8a Merge pull request #1309 from dotCooCoo/feat/artboard-tool-active-artboard
Artboards: the Artboard tool starts on the active artboard
2026-10-12 08:19:25 +07:00
dotCooCoo f8f40e7902 Artboards: the Artboard tool starts on the active artboard
Each tool switch builds a new tool, and a new Artboard tool started on
Artboard 1 whatever artboard was active. With Artboard 2 active, after
the tool was chosen with Shift+O, its toolbar button, Edit Artboards in
Properties or Document Setup, or the Artboards panel menu's Artboard
Options…, the canvas outlined Artboard 1, and Delete, Cut, Copy, the
Transform panel and Artboard Options acted on Artboard 1. The first
pointer event over the canvas, a hover included, then made Artboard 1
the active artboard for Align to Artboard and Paste in Place too.

VectorcraftApp::select_tool now starts a newly chosen Artboard tool on
the active artboard, as the Artboards panel row's Options button does.
Choosing the tool again while it is in use leaves it as it was, and the
artboards Shift-clicked into its selection stay selected.

The test makes Artboard 2 active, chooses the tool with tool.select,
and checks the tool's active option, Artboard Options, a hover over
Artboard 1 and Delete. It then presses Shift+O with two artboards
selected and checks that both stay selected.

Docs: docs/mcp.md (tool.select), docs/target-app-parity.md.
2026-10-12 08:19:12 +07:00
BFlat a40454a4a0 Merge pull request #1302 from Skaywalker/i18n-hu
i18n: add Hungarian (hu) UI translation
2026-10-12 08:19:02 +07:00
bflatastic 48c52787a1 i18n (hu): rows for the strings added since the branch (graph designs and data, center align, Find Linked Files, Film Grain, Select Segments) 2026-10-12 08:18:45 +07:00
Skaywalker b9716366bf i18n: add Hungarian (hu) UI translation
Add the Hungarian catalog (4,524 rows): every menu string, every `tl!`
literal, the contextual and `@id` rows, the plurals and the status and
error messages, written from the meaning of the English labels in
general Hungarian vector-illustration vocabulary. Conventions are in the
header of `hu.tsv`.

- Register `hu` ("Magyar") in `i18n::LANGUAGES` as a complete language
  with `plural_none` (a count takes the singular); every `hu-*` locale
  resolves to it.
- Tests: Hungarian joins the menu-label, menu-string, `tl!`-literal and
  message coverage checks; `hungarian_reads_as_hungarian` pins the core
  vocabulary, the plural, the `file.close` override and a nested
  message; ő ű Ő Ű are checked in the bundled UI fonts.
- README, ROADMAP, docs/development.md and docs/localization-parity.md
  list Hungarian.
2026-10-12 08:18:45 +07:00
BFlat 70e35807e7 Merge pull request #1259 from Losminek/cs-ui-code
i18n (cs): translate labels that bypass the catalog lookup (file filters, diagnostics, credits, gradients, print marks)
2026-10-12 08:18:35 +07:00
LosminekandClaude Opus 5.5 e3dec2e87b M14.i18n: translate Czech labels at UI render sites
Localize native/web file filters, diagnostic labels, numbered presets
and contributor tooltips with English fallback and Czech plural forms.
Fix gradient adjective agreement and disambiguate Japanese printer marks
in PDF/Print choices and summaries. Keep file extensions and names intact.

Add Czech catalog rows and regression coverage; update localization evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:18:13 +07:00
BFlat 25c60fd0f5 Merge pull request #1255 from FredApps/fix/layer-target-validation-785-20261011
Validate explicit targets in layer row commands (part of #785).
2026-10-12 08:17:58 +07:00
FredApps 714f127100 M2: reject malformed explicit layer targets (#785) 2026-10-12 08:17:51 +07:00
BFlat 490989a9f7 Merge pull request #1294 from LucaLorenz/fix/document-setup-dialog-close
Fix Document Setup → Edit Artboards dialog handoff
2026-10-12 08:17:46 +07:00
Luca Lorenz 981f66441e fix(M11): close setup before artboard editing 2026-10-12 08:17:34 +07:00
BFlat d827dc92da Merge pull request #1296 from LucaLorenz/fix/artboard-escape-exit
Fix Escape exiting Artboard editing
2026-10-12 08:17:29 +07:00
Luca Lorenz f602dc3d0b fix(M11): claim Escape in the Artboard tool 2026-10-12 08:17:15 +07:00
BFlat 0586ee863d Merge pull request #1287 from dotCooCoo/feat/link-search
Links: Find in Folder… looks for missing linked files in a folder and its subfolders
2026-10-12 08:16:54 +07:00
bflatastic 5bcd642b76 Links: Find Linked Files tests build paths with the platform separator 2026-10-12 08:16:41 +07:00
bflatastic 7f4f2600e2 Links: the copy test builds the document folder's paths with the platform separator 2026-10-12 08:16:41 +07:00
dotCooCoo b3f832cbcd Links: Find in Folder… looks for missing linked files in a folder and its subfolders
Find in Folder… in Linked File Not Found and in the Links panel menu
looks in a folder the user picks, and in its subfolders, for the files
with the names of missing linked files, in any letter case. In the
Links panel menu it looks for the selected linked files when there are
any. It uses the folder search of the Missing Fonts dialog. Find
Linked Files lists each file it looks for with the files found for it.
A file with the size and contents saved with the link comes first. A
file of the saved size comes next when the link saved no hash or the
file is larger than 1 GB, and any other file of the name comes last.
Within each group, a file named in the same letter case comes first.
Then each file's folder names are compared with the saved path's, from
the innermost outward, up to the first one that differs, and a file
with more matching names comes before one with fewer. A file is chosen
when it is the only file found, or the only one with the saved
contents, and the user can choose another file or Don't Relink.

Relink relinks each file chosen, and each relink is one undo step.
With Copy into the document's folder, which shows for a saved
document, each image's file is copied next to the document first. The
file's name, then Name 2.png, Name 3.png and so on, are tried in turn
until one is free, which gets the copy, or is a file with the same
contents, which is used. A placed document is relinked where its file
is. The status line names the first file that fails to relink, and the
other files still relink. When Find Linked Files opens from Linked
File Not Found, the missing files left without a choice, and any that
fail to relink, go back to that dialog. On the web, Find in Folder… is
disabled in the Links panel menu, and links.findFiles can't start a
search.

- links.findFiles searches on separate threads and returns at once;
  links.findFiles {} reports the search, and {stop: true} stops it. It
  looks for at most 1,000 linked files at once: the active document's
  missing ones, or those of the objects it is given. It hashes a file
  only when a link of its name saved the file's size and a hash, and
  only when the file is 1 GB or smaller. It ends once every linked
  file has its same file, and it searches only the folders
  text.findFontFiles may search, with the same maxSeconds, depth and
  count limits.
- links.relink {copy} copies the file it is given into the saved
  document's folder and links images to the copy; a call that includes
  a placed document fails. Each name it writes is checked against the
  automation write root. text.addFontFiles and links.relink {copy}
  make their copies with fileio::copy_new, which never replaces a
  file. The text.addFontFiles params text and docs/mcp.md say which
  names are tried for a copy.
- vectorcraft-doc adds links::hash_reader, which hashes what a reader
  reads as hash_bytes hashes bytes.
- ui.findLinksInFolder is the Links panel's Find in Folder… for agents;
  docs/control-protocol.md lists the dialog's fields.

Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
2026-10-12 08:16:41 +07:00
BFlat b865547bc0 Merge pull request #1301 from dotCooCoo/feat/send-to-current-layer-refuses-hidden
Layers: Send to Current Layer refuses a hidden or locked current layer
2026-10-12 08:16:37 +07:00
dotCooCoo fd2ab03cd9 Layers: Send to Current Layer refuses a hidden or locked current layer
A click on a Layers row runs layer.setCurrent, which makes the row's
layer current whether it is hidden or locked and keeps the selection.
Object › Arrange › Send to Current Layer then moved the selected
objects into that layer, or into a sublayer of a hidden or locked
layer, and left them selected. In that case drawing, typing and
pasting put new art on the topmost layer that is shown and unlocked,
and since #1039 they fail with "the target layer is locked or hidden"
when no layer is shown and unlocked.

Send to Current Layer now fails with that error when the current layer
is hidden or locked, or is inside a hidden or locked layer. It makes
the check that DocState::target_parent makes for new art
(Document::is_editable) before it moves anything or records an undo
step. The objects stay where they are and stay selected. The message
already has a row in the cs, de, es, fr, it, ja, pt-br, ru and uk
catalogs.

Docs: docs/target-app-parity.md.
2026-10-12 08:16:24 +07:00
BFlat 431e282c0d Merge pull request #1303 from dotCooCoo/feat/transform-again-options
Transform: Transform Again repeats the options of the transform it repeats
2026-10-12 08:16:19 +07:00
dotCooCoo ac8d7ebce8 Transform: Transform Again repeats the options of the transform it repeats
Transform Again kept only the last transform's matrix and its Copy
flag. After a Selection tool handle drag resized area type, Transform
Again scaled the type instead of resizing the type area again. After
Move, Rotate, Scale, Reflect or Shear with Transform Patterns set
differently from General › Transform Pattern Tiles, Transform Again
followed the preference, and after a scale whose strokes or corners
param differed from Scale Strokes & Effects or Scale Corners, it
followed those preferences. A recorded Transform Again played back
with the preferences in effect at playback, although its step records
the strokes, corners and patterns values it used.

last_transform now keeps the options with the matrix, as
last_perspective keeps the perspective.transform params.
apply_transform stores the copy, typeAreas and patterns values a
transform command used, and strokes and corners when it scales.
commit_interaction stores the params of a drag's last object.transform
preview with the values that preview noted. object.transformAgain
passes the options to the transform it repeats, and a true or false
value given in its own params under one of those names takes
precedence. A recorded step plays back with the values it records.

Three engine tests fail without the change: Transform Again after a
handle drag on area type, Transform Again after a Move with patterns:
false while Transform Pattern Tiles is on and after a Scale with
strokes: true while Scale Strokes & Effects is off, and a recorded
Transform Again played back with Transform Pattern Tiles on.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-12 08:16:07 +07:00
BFlat f714a3f9ff Merge pull request #1314 from dotCooCoo/feat/grid-prefs-new-documents
Part of #394: new and imported documents use the Gridline every and Subdivisions preferences
2026-10-12 08:15:57 +07:00
dotCooCoo 9ba45990ec Part of #394: new and imported documents use the Gridline every and Subdivisions preferences
Preferences › Guides & Grid › Gridline every and Subdivisions set the
grid of the documents that were open when either value changed, and of
no other document. A document made with File › New, or opened from a
file VectorCraft reads no grid from (SVG, PDF, EPS, .ai and the other
imported formats), got a 72 pt grid with 8 subdivisions, whatever the
preferences were set to. After a restart the saved values applied to no
document, because the desktop app restores the preferences at launch,
before any document is open.

file.new now gives a new document the preferences' grid, and
document.open, file.newFromTemplate and file.revert give it to a
document read from such a file. A native file, and the native document
that an SVG, PDF, .ai or EPS file carries, keep the grid they were
saved with. Changing either preference still sets the grid of every
open document.

Docs: docs/mcp.md (gridlineEvery, gridSubdivisions),
docs/target-app-parity.md.
2026-10-12 08:02:13 +07:00
BFlat 60346d98b7 Merge pull request #1306 from itrycodinganditsbad/fix-1273
Pen + Alt converts anchors of paths in a selected group (#1273)
2026-10-12 07:57:02 +07:00
BFlat f0d2727843 Merge pull request #1305 from haranishi/fix/issue-1272-simplify-preview
fix: Simplify後の選択を更新して点数を表示する #1272
2026-10-12 07:56:48 +07:00
BFlat 86cc73be34 Merge pull request #1282 from Tevinhead/feat/artistic-film-grain
G2: add Artistic Film Grain live effect
2026-10-12 07:51:04 +07:00
BFlat 68b5011d18 Merge pull request #1290 from haranishi/fix/issue-1275-rpm-runtime-dependencies
fix(packaging): RPMの必須依存を共有ライブラリ能力で宣言 (#1275)
2026-10-12 07:50:48 +07:00
BFlat e30147d15c Merge pull request #1265 from haranishi/perf/issue-1257-craquelure-cell-search
perf: テクスチャ効果の距離判定を軽くする #1257
2026-10-12 07:50:31 +07:00
BFlat ea66635af5 Merge pull request #1291 from imachus/pr/ja-proportional-kerning
Type: Proportional Metrics bring their kerning — `apkn` with `palt`, `vkrn` and `vapk` with `vpal` (#966)
2026-10-12 07:49:54 +07:00
Fabio LussiandClaude Opus 5.5 8b839dd50f Snap to Point: drag a selection by the anchor pressed near
With Smart Guides off, the Selection tool grabbed the selection by the
pressed spot, so Snap to Point brought the pointer onto another anchor
while the object's own anchor stayed a few pixels off. A press within
reach of an anchor now grabs the object by that anchor (Smart Guides on
or off, also without a hover event first), and the arrow turns hollow
over such an anchor while Snap to Point is on.

Fixes #1274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:49:47 +07:00
BFlat ae3ee3ed0b Merge pull request #1284 from haranishi/fix/issue-1236-curvature-points
fix: Curvature Toolの点表示を整理する #1236
2026-10-12 07:49:38 +07:00
BFlat 5734888cf3 Merge pull request #1267 from haranishi/fix/issue-1258-full-font-names
fix: フォントのフルネームを正しい書体へ解決する #1258
2026-10-12 07:49:20 +07:00
BFlat fade678fb9 Merge pull request #1276 from haranishi/fix/issue-1180-align-noop-history
fix: 動かないアンカー整列でUndo履歴を増やさない #1180
2026-10-12 07:49:05 +07:00
BFlat 7c62536e37 Merge pull request #1288 from haranishi/fix/issue-1269-eyedropper-cursor
fix: スポイトの通常カーソルを十字表示から修正する #1269
2026-10-12 07:48:51 +07:00
BFlat 919abd4928 Merge pull request #1260 from haranishi/fix/issue-1251-readable-color-sources
fix: ロック中と隔離の外側の図形から色を取得する #1251
2026-10-12 07:48:36 +07:00
BFlat cd7895f717 Merge pull request #1300 from haranishi/fix/issue-1278-direct-segment-delete
fix: Direct Selectionで線分だけを選択・削除する #1278
2026-10-12 07:48:18 +07:00
BFlat 744ff99f34 Merge pull request #1263 from itrycodinganditsbad/fix-1254
Direct Selection: Shift-click deselects one anchor of a path selected whole
2026-10-12 07:48:03 +07:00
BFlat b3be342360 Merge pull request #1256 from haranishi/fix/issue-1253-stroke-default-width
fix: 線の色追加時にゼロ幅を既定値へ戻す #1253
2026-10-12 07:47:51 +07:00
Fabio LussiandClaude Opus 5.5 5cbb7989f9 Pen + Alt converts anchors of paths in a selected group
Fixes #1273. A selected group or compound path shows its paths' anchors
with the Pen, but Pen + Alt only looked at paths selected directly, so an
Alt-click there started a new path instead of converting the anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:26:11 +07:00
Ikki Nishihara ba4a870750 fix: Simplify後の選択を更新して点数を表示する #1272 2026-10-12 07:26:09 +07:00
bflatastic f683f0ff29 Film Grain dialog: the sliders use the shared grey rail 2026-10-12 07:26:06 +07:00
Tevinhead 810af8d001 G2: add Artistic Film Grain live effect
Add deterministic document-space grain, highlight controls and a live
dialog with preview, cancel, undo and appearance editing. Preserve
coverage, support existing native and raster export paths, translate
new labels and update parity documentation.

Validate the tonal curve, stationarity, subpixel strength, zoom filtering
and tile seams. Full cargo xtask ci passes all seven gates.
2026-10-12 07:26:05 +07:00
Ikki Nishihara 1475a0798d fix(packaging): RPMの依存を共有ライブラリ能力に修正 (#1275) 2026-10-12 07:26:02 +07:00
Ikki Nishihara d0569b7fe7 perf: テクスチャ効果の距離判定を軽くする #1257 2026-10-12 07:25:58 +07:00
imachusandClaude Opus 5.5 876f8e283c Type: Proportional Metrics bring their kerning — apkn with palt, vkrn and vapk with vpal (#966)
The follow-up #966 left room for. With automatic kerning, horizontal type
with Proportional Metrics also applies `apkn`, and vertical type applies
`vkrn` and `vapk` along with `vpal`. Manual kerning turns them off, as it
turns off `kern`.

Vertical kerning used to cancel out: both top-to-bottom shapings had
harfrust's default `vkrn`. The baseline now has neither `vpal` nor the
kerning, so their difference carries both. A third shaping (`vpal` alone)
tells which advances `vpal` itself changed, and mojikumi steps aside only
for those. Vertical type without Proportional Metrics isn't kerned, as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:25:55 +07:00
Ikki Nishihara 68c3cd56f7 fix: Curvature Toolの点表示を整理する #1236 2026-10-12 07:25:54 +07:00
Ikki Nishihara ce38eef890 fix: フォントのフルネームを正しい書体へ解決する #1258 2026-10-12 07:25:51 +07:00
Ikki Nishihara 4109717a9a fix: 動かないアンカー整列でUndo履歴を増やさない #1180 2026-10-12 07:25:48 +07:00
Ikki Nishihara 978aa97396 fix: スポイトの通常カーソルを十字表示から修正する #1269 2026-10-12 07:25:46 +07:00
Ikki Nishihara 2dc37d424d fix: ロック中と隔離の外側の図形から色を取得する #1251 2026-10-12 07:25:43 +07:00
bflatastic 4149a09d61 Direct Selection: a Shift-click on a segment pick's endpoint selects that anchor (#1278)
The endpoints of a selected segment are hollow, so select.anchors and select.anchorsMany now
start from no anchors of that path instead of toggling the endpoint off.
2026-10-12 07:25:39 +07:00
Ikki Nishihara 1ac797685f fix(selection): 線分の選択と削除をアンカーから分ける (#1278) 2026-10-12 07:25:36 +07:00
Fabio LussiandClaude Opus 5.5 aa15cb52bd Direct Selection: Shift-click deselects one anchor of a path selected whole
select.anchors in toggle and add mode now starts from all of a path's
anchors when the path is selected as a whole, as select.anchorsMany does.
Before, Shift-clicking an anchor of a fully selected path left only that
anchor selected.

Follow-up to #1237 / #1254.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:25:32 +07:00
bflatastic 8362223932 Appearance panel: a stroke colour on an unpainted zero-width stroke restores 1 pt too (#1253)
The case #1297 also covered: appearance.setItem with a colour and no weight.
2026-10-12 07:25:31 +07:00
Ikki Nishihara 2f64b5a86b fix: 線の色追加時にゼロ幅を既定値へ戻す #1253 2026-10-12 07:25:29 +07:00
BFlat 540505fcad Merge pull request #1310 from storytold/docs/progress-1012
Docs: ROADMAP progress for 2026-10-12
2026-10-12 07:23:09 +07:00
bflatastic 86d45dc6bd Docs: ROADMAP progress for 2026-10-12 (community PRs landed overnight) 2026-10-12 07:22:55 +07:00
BFlat 611c792e59 Merge pull request #1247 from japbcoelho/fix/trace-close-dark-tones
Trace: colour mode keeps close dark tones apart
2026-10-12 07:21:03 +07:00
BFlat 367fca5c5e Merge pull request #1246 from KnottyDyes/squad/no-replay-after-lost-reply
MCP: lost replies report uncertainty instead of replaying edits
2026-10-12 07:20:48 +07:00
BFlat 838917f7cd Merge pull request #1249 from japbcoelho/fix/cmyk-percent-one-scale
Color params: one scale per CMYK colour, not per component
2026-10-12 07:20:32 +07:00
BFlat 17eee66c19 Merge pull request #1073 from Muratv07/feature/align-both-centers
feat(ui): add horizontal and vertical center align button
2026-10-12 07:20:16 +07:00
BFlat ec51bb3251 Merge pull request #718 from sanyamsingh001/codex/file-interop-main
Preserve placed links in editable exports and honor AI artboard options
2026-10-12 07:20:04 +07:00
BFlat 06811241b6 Merge pull request #821 from sanyamsingh001/codex/pen-endpoints
Fix Pen endpoint picking and commit handle previews
2026-10-12 07:19:52 +07:00
BFlat 53bc9d4a85 Merge pull request #993 from sanyamsingh001/codex/font-test-oracles
Correct Arabic shaping assertions and cover embedded PDF fonts
2026-10-12 07:19:26 +07:00
BFlat 1fdd7d2146 Merge pull request #989 from sanyamsingh001/codex/svg-data-attributes
Preserve SVG data attributes and validate export artboard ranges
2026-10-12 07:19:13 +07:00
BFlat e38e48a03b Merge pull request #1138 from IHAGI-c/feat/graph-data-sheet
Graph Data: a worksheet window
2026-10-12 07:18:53 +07:00
BFlat 75732b3be8 Merge pull request #1137 from IHAGI-c/feat/graph-label-styles
Graphs keep their label formatting when drawn again
2026-10-12 07:18:36 +07:00
BFlat 0cdf51b720 Merge pull request #1136 from IHAGI-c/feat/graph-overlap-filled-lines
Graph Type: widths past 100%, First Row and First Column in Front, Draw Filled Lines
2026-10-12 07:18:19 +07:00
BFlat 38061b9515 Merge pull request #1119 from IHAGI-c/feat/graph-shadow-legend-top
Graph Type: Add Drop Shadow and Add Legend Across Top
2026-10-12 07:18:07 +07:00
BFlat b8aa326930 Merge pull request #1115 from IHAGI-c/feat/graph-pie-data
Graph Data: Transpose and Switch x/y; | line breaks in labels; pie Legend, Position and Sort
2026-10-12 07:17:50 +07:00
BFlat c71f57941e Merge pull request #1092 from IHAGI-c/feat/graph-marker-designs
Graph designs and marker designs: Object › Graph › Design… and Marker…
2026-10-12 07:17:35 +07:00
japbcoelhoandClaude Opus 5.5 4268b8edd5 Trace: colour mode keeps close dark tones apart
The colour histogram used the top 5 bits of each channel, so tones less
than 8 values apart always shared a bin and could never get their own
palette entry, however many colours were asked for. A dark, low-contrast
photo (a book-cover texture with RGB 33..66) traced to 5 colours when 12
were requested, and eight grey stripes 32..39 traced to one:

  vectorcraft-cli run --in stripes-32-39.png --cmd select.all \
    --cmd imageTrace.makeAndExpand \
    --params '{"params":{"mode":"color","colors":8,"noise":0,"method":"overlapping"}}'
  -> {"anchors":4,"colors":1,"id":4,"paths":1}

The histogram keeps its 32x32x32 bins, now fitted to each channel's range
among the opaque pixels: a channel spanning at most 32 values keeps every
value apart, a full-range channel keeps the old 5-bit bins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:59:50 +07:00
KnottyDyes 44f3141c3d MCP: lost replies report uncertainty instead of replaying edits
Drop a failed control connection and reconnect for the next request, never resend an operation that may already have completed.

A loopback regression applies a rectangle then drops its reply. It reproduces two edits before the fix and verifies one edit, an error, and successful inspection afterward.
2026-10-12 06:59:49 +07:00
japbcoelhoandClaude Opus 5.5 650c5a00a6 Color params: one scale per CMYK colour, not per component
paint.setFill {"color": {"c": 18.16, "m": 13.39, "y": 13.9, "k": 0.79}} stored
K 79 %: recolor.colors listed the colour as "cmyk 18.16 13.39 13.9 79" and the
exported PDF printed it as a dark grey. color_value chose the scale for each
component on its own (above 1: percent, else a fraction), so a percentage of
1 or less beside larger ones became a fraction.

A {c, m, y, k} object now takes percent for every component as soon as one is
above 1, and fractions only when all four are within 0..1, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:59:49 +07:00
bflatastic e1beafb359 Align: keep the tests and panel docs #1073's branch dropped, and translate the new button
The branch was cut from an older main, so its commit also removed four engine tests (stroke and
paint rejection, malformed anchor selection, bad anchors in path commands), the newer
window.panel.float/dock parameter docs and the un-collapse on showing a floating panel. This puts
them back and adds the Horizontal & Vertical Align Center label to the complete catalogs.
2026-10-12 06:59:49 +07:00
sanyamsingh001andCodex 365e7f2896 IO.1: preserve editable export data and AI artboard options
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 06:59:49 +07:00
sanyamsingh001 f9a6c10719 PEN.1: fix endpoint picking and commit handle previews
Co-authored-by: Codex <noreply@openai.com>

# Conflicts:
#	contributors/people.toml
2026-10-12 06:59:49 +07:00
sanyamsingh001andCodex 21603e6c81 TEST.1: correct font shaping and embedding oracles
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 06:59:49 +07:00
sanyamsingh001andCodex 7a49c49ade SVG.1: preserve data attributes and validate export ranges
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 06:59:49 +07:00
bflatastic 110bb50e23 Graph Marker: a None entry goes back to the default marker
The dialog opens on None when the series draw the default square, and OK with None
runs graph.marker {design: null}.
2026-10-12 06:59:49 +07:00
Murat Vural a672bd70dd feat(ui): add horizontal and vertical center align button
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-12 06:59:49 +07:00
IHAGI-c 27141b8722 Graph Data: a worksheet window
Object › Graph › Data (and the graph tool after drawing) opens the
Graph Data window as a worksheet, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html
and
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/adjust-column-width.html
describe, in place of a free-text CSV field.

- The first row holds the series labels, the first column the category
  labels, the other cells the values. A click picks a cell, edited in
  the entry box above the sheet: Tab enters it and moves right, Enter
  down (Shift: the other way), the arrow keys move between cells.
  Pasting tab- or line-separated text fills cells from the picked one.
- Import Data reads a tab-separated text file (UTF-8, or UTF-16 with a
  byte-order mark), at most a graph's 10,000 × 256 cells.
- Transpose row/column and Switch x/y (scatter graphs) rearrange the
  sheet; Revert reads the graph's data again; Cell Style sets the
  decimals shown and the columns' width. Apply redraws the graph and
  leaves the window open.
- The sheet reads and writes the graph's series, categories and rows
  (not CSV), so labels with commas, quotes or `|`, blank labels and
  graphs without category labels come back as they were.
- Only the cells in view are drawn, read in place, so a full-size sheet
  stays responsive.

Own choices, as the pages don't say: Number of Decimals only changes
what the sheet shows; Transpose is off when it would make more series
than a graph holds; the arrow keys move between cells rather than the
caret. Dragging a column's edge to resize it isn't supported yet.
2026-10-12 06:43:09 +07:00
IHAGI-c e4c03b5cfd Graphs keep their label formatting when drawn again
Text formatted in a graph, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-the-text-in-graphs.html
describes (the whole graph with the Selection tool, or labels picked
with Group Selection, through the Character and Appearance controls),
now survives the graph being drawn again from its data.

- A graph keeps a character style per kind of label: value axis
  numbers, category labels (category axis, radar spokes, pie names)
  and legend labels (beside or across the top, or in wedges).
- text.setStyle, text.setFormat and fill/stroke changes on a graph's
  labels apply the same change to their kind's kept style, so only what
  the user changed is kept and every label of that kind follows. A
  change to part of a label's text isn't kept.
- Labels are told apart by where the graph draws them, not by their
  text, so a category named "2" and the value axis' 2 stay apart.
- Label spacing follows a kept size (legend rows, offsets from the axes
  and wedges), so bigger labels don't overlap; at 9 pt it's as before.
  Wedge labels stay white on dark wedges unless given a colour.
- A kept style from a file is held to the Character controls' limits.

Own choice, as the page doesn't say what happens on a data change:
formatting one label formats its whole kind, since the labels are drawn
again from the data. Other ways of restyling text (eyedropper,
character styles, Find Font) aren't kept yet. New fields are written
only when set; graphs without kept formatting draw the same art as
before.
2026-10-12 06:42:50 +07:00
IHAGI-c 4de410c7c8 Graph Type: widths past 100%, First Row and First Column in Front, Draw Filled Lines
Graph Type's column, bar and cluster widths from 1% to 1000%, overlapping
past 100%, the overlap order, and Draw Filled Lines, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-columns-bars-and-lines.html
describes.

- Column and cluster widths take 1–1000% and are drawn as set: past
  100% columns (or clusters) overlap. The legend moves past columns
  that reach beyond the plot.
- firstColumnInFront draws the first series over the others (in each
  layer); firstRowInFront draws each series' first category over the
  next. Across series the series order decides, so each series stays
  one group for Group Selection.
- filledLines draws the connecting lines of line, scatter and radar
  graphs as 3 pt bands filled with the series colour (set as a fill or,
  on a thin line, a stroke) with a thin black outline. A run with no
  outline to make, or a graph past 20,000 data points, keeps thin lines.
- The Graph Type query lists the overlap options only for graphs with
  columns or bars, and filled lines only for graphs with lines.

Own choices, as the page doesn't say: both overlap options start off
(today's order), and the band is 3 pt wide. New fields are written only
when set. Graphs with default options draw the same art as before.
2026-10-12 06:42:50 +07:00
IHAGI-c fe33dec0bb Graph Type: Add Drop Shadow and Add Legend Across Top
Graph Type's Add Drop Shadow, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-drop-shadows.html
describes, and Add Legend Across Top, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-the-position-of-legends.html
describes.

- dropShadow: a shadow behind each column, bar, stacked segment, line
  (line, scatter and radar) and pie wedge, just behind the series of
  its layer, so areas and columns drawn in front don't hide the shadows
  of the layer behind them.
- legendAcrossTop: the legend's swatches and labels left to right from
  the plot's left edge, wrapping at the plot's width, the last row just
  above the plot and anything drawn over it (radar and stacked-pie
  labels).

Own choices, as the pages don't say: a shadow is a black copy 2 pt down
and right at 35% opacity; markers, areas and legend swatches cast none,
nor do stacked segments of 0; a graph with more than 200,000 shadowed
marks draws none.

New fields are written only when set. Graphs with default options draw
the same art as before.
2026-10-12 06:42:50 +07:00
IHAGI-c 5e4a282f90 Graph Data: Transpose and Switch x/y; | line breaks in labels; pie Legend, Position and Sort
Graph Data's Transpose row/column and Switch x/y, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html
describes, a `|` in a label as a line break, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-labels-and-data-sets.html
describes, and the pie graph options of
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-pie-graphs.html

- graph.setData {transpose: true} swaps rows and columns (after any new
  data in the same call); a graph with more categories than a graph can
  have series (256) is refused rather than cut. {switchXY: true} swaps
  each scatter series' y and x columns, labels included.
- A `|` in a category or series label starts a new line: legend rows
  grow with their lines, labels beside an axis or in a wedge centre
  theirs. A label of only `|` is blank.
- Pie graphs: pieLegend none|standard|wedges (Legends in Wedges puts the
  series labels inside their wedges, in white on dark wedges, drawn over
  the pies), piePosition ratio|even|stacked and pieSort all|first|none,
  as dropdowns in the Graph Type dialog, which lists them for pie graphs
  only.

Own choices, as the page doesn't say: Position and Sort keep today's
drawing by default (Even, None); Ratio sizes pies by area, the biggest
keeping the full size; Stacked pies share a centre, each drawn as the
ring outside the next smaller one so none covers another, with their
names over them.

New fields are written only when set. Graphs with default options draw
the same art as before.
2026-10-12 06:42:50 +07:00
IHAGI-c 62aceb205c Review: Graph Design dialog as Illustrator's (preview, New Design, Rename, Delete Design, Paste Design, Select Unused)
The Graph Design dialog lists the designs with a preview of the
selected one and has New Design, Rename, Delete Design, Paste Design
and Select Unused, as the Graph Design dialog of
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/reuse-graph-designs.html
works; the Marker dialog lists the designs.

graph.design takes them as params:
- new: name or true (the selected art, named "New Design", numbered
  when that's taken);
- rename + to: the graphs drawing the design follow it to its new name;
- delete: a name or a list of names;
- paste: as before;
- no params or selectUnused: the designs, and those no graph draws with.

The engine's drawing is unchanged.
2026-10-12 06:42:50 +07:00
IHAGI-c 3979872283 Graph designs and marker designs: Object › Graph › Design… and Marker…
Graph designs are named art a graph draws in place of its default
marks. Marker designs follow
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/apply-marker-designs-to-graphs.html
and the Graph Design dialog
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/reuse-graph-designs.html

- graph.design saves the selected art as a named design (Save Design),
  pastes a copy of a design into the document, selected, to edit and
  save again (Paste Design), and deletes one; graphs drawing a deleted
  design go back to their default marks.
- graph.marker draws the data points and legend swatches of the series
  picked with Group Selection (or listed, or every series) with a
  design: its backmost object is scaled to fill the default marker's
  square, strokes, effects and pattern tiles scaling with it. Line,
  scatter and radar series; null goes back to the default square.
- Object › Graph › Design… and Marker… open dialogs listing the
  document's designs.

Own choices, as the pages don't say: the backmost object's bounds, not
only a rectangle's, set the size (a single object uses its own bounds);
Delete Design manages the list; a design keeps its own paint, so
painting a series doesn't recolour it.

Designs are stored in a new `graphDesigns` list and series designs in
`seriesMarkers`, written only when present. A graph saved as a design is
kept as the plain art it draws. A design holds at most 2,000 objects, a
document 1,000 designs, and a graph whose designs would draw more than
200,000 objects draws default squares; a file's designs are tidied the
same way on open. Global swatch edits, image relinking and placed
documents see design art like symbol art. Graphs without marker designs
draw the same art as before.
2026-10-12 06:42:49 +07:00
BFlat 2ac3ba2b86 Merge pull request #1169 from FredApps/fix/rectangular-grid-click-dialog-validated-20261011
Fix Rectangular Grid click options dialog
2026-10-12 01:31:04 +07:00
BFlat 734afd5ca3 Merge pull request #1226 from Losminek/cs-pathfinder-add
i18n (cs): Pathfinder Add reads "Sjednotit", plus the remaining looked-up strings
2026-10-12 01:30:46 +07:00
FredApps c570414873 M2: Rectangular Grid click options dialog 2026-10-12 01:26:45 +07:00
LosminekandClaude Opus 5.5 e61ed740c3 i18n (cs): translate the remaining looked-up strings (shortcut editor, arrowheads, colour harmony, presets categories, …)
These strings reach tl!/tr at render time but had no Czech entry; several are passed through variables or helpers, so the literal-based coverage test does not see them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:26:45 +07:00
LosminekandClaude Opus 5.5 6371211f3b i18n (cs): translate the remaining new-document preset names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:26:45 +07:00
LosminekandClaude Opus 5.5 660fe22de8 i18n (cs): Effect > Pathfinder > Add reads "Sjednotit", keyed by the effect id
The key "Add" is shared by Effect > Pathfinder > Add (PathfinderOp::Unite),
the File Info button and a label in the raster effects dialog. One plain row
cannot fit all three: "Přidat" is right for the button, but the Pathfinder
effect unites shapes, so it reads "Sjednotit".

- cs.tsv: `@id pathfinder.add` = "Sjednotit"; the plain "Add" row stays
  "Přidat".
- menus.rs: Effect menu items all run effect.apply / effect.dialog, so the
  command id cannot tell them apart. effect_item_label() looks the effect's
  own id up first (an @id row), and "Last Effect: {name}" does the same.
- appearance.rs: the Appearance panel's effect labels use the same lookup.
- i18n/tests.rs: an @id row may now name an effect id as well as a command
  id; czech_reads_as_czech pins Add and pathfinder.add.

No other catalog has an @id row, so their behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:26:44 +07:00
BFlat 5b9af8103a Merge pull request #1238 from zakariayacine/fix-view-cursors
Hand and Zoom cursors drawn like the tool cursors (Windows showed the move arrows, no magnifier)
2026-10-12 01:26:12 +07:00
BFlat 9fe75fa85c Merge pull request #1237 from zakariayacine/fix-direct-click-anchor
Direct Selection: a click on an already selected anchor selects it alone
2026-10-12 01:25:50 +07:00
BFlat 14bcdaf65b Merge pull request #1235 from haranishi/fix/issue-866-macos-save-format
fix: macOSの保存画面に形式選択を追加する #866
2026-10-12 01:24:55 +07:00
bflatastic 1c19bf160a Hand and Zoom cursors: draw the hand's parts without halos over their outlines, the plus whole 2026-10-12 01:15:18 +07:00
zakariayacineandClaude Fable 5.1 9d6f43c149 Hand and Zoom cursors drawn like the tool cursors
On Windows the Hand tool, Space and the Zoom tool showed the wrong
cursors: winit maps egui's Grab and Grabbing to the four-headed move
cursor (IDC_SIZEALL) and has nothing for ZoomIn/ZoomOut, so the hand
appeared as arrows and the magnifier not at all. Those four were the
only tool cursors left to the system.

They are now vector glyphs like the others (an open and a closed hand,
a magnifier with a plus or a minus), shown as OS cursors on the desktop
and painted on the web, and the canvas routes the Space, Hand and Zoom
states through the same path as the tool cursors.

Tests: the glyphs rasterize at every scale with the others; only the
busy states stay system cursors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 01:15:18 +07:00
zakariayacineandClaude Fable 5.1 db7efb7923 Direct Selection: a click on an already selected anchor selects it alone
Clicking an anchor of a path selected whole (or one of several selected
anchors) with the Direct Selection tool left the selection as it was,
so the clicked anchor never became the selected one and Live Corners
and the anchor controls kept acting on every corner. A plain click now
selects that anchor alone, as the public documentation describes (a
click selects the anchor point, Shift-click adds others):
https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/select-anchor-points-in-paths.html

The press keeps the selection so a drag still moves every selected
anchor with the one held; only the release without a drag narrows it.
Shift still toggles, Alt still picks the whole path, and an anchor that
wasn't selected is still selected on the press.

Test: direct::tests::a_click_on_a_selected_anchor_selects_it_alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 01:15:17 +07:00
Ikki Nishihara f08c970588 fix: macOSの保存画面に形式選択を追加する #866 2026-10-12 01:15:16 +07:00
BFlat 53bc86fdb4 Merge pull request #1220 from dotCooCoo/feat/envelope-test-pdf-dates
Tests: the envelope export test ignores the PDF's XMP dates and file ID
2026-10-12 01:00:01 +07:00
BFlat e3cdb187ac Merge pull request #1218 from dotCooCoo/feat/sweeps-fail-on-caught-panics
Tests: the command sweeps and import fuzz tests fail when a command panics
2026-10-12 00:59:47 +07:00
BFlat 2b34560de0 Merge pull request #1224 from dotCooCoo/feat/palette-cancel-key-object
Palette: the entry that cancels the key object reads Cancel Key Object
2026-10-12 00:59:30 +07:00
BFlat 0e6fa89ad6 Merge pull request #1222 from dotCooCoo/feat/select-key-checks-ids
MCP: select.key checks its id, and agents can read the key object
2026-10-12 00:59:12 +07:00
BFlat 085f8bc9d2 Merge pull request #1221 from dotCooCoo/feat/align-key-object-topmost
Align: Align to Key Object makes the topmost selected object the key
2026-10-12 00:58:56 +07:00
BFlat c26e30dd1f Merge pull request #1230 from dotCooCoo/feat/pathops-planar-regression
Shape Builder: a line's loose end past a crossing no longer runs out and back in most cases
2026-10-12 00:58:38 +07:00
BFlat 01b6936c27 Merge pull request #1219 from dotCooCoo/feat/artboard-moves-pattern-tiles
Part of #394: art moved or duplicated with an artboard follows Transform Pattern Tiles
2026-10-12 00:57:57 +07:00
BFlat 0fd9709fdb Merge pull request #1233 from dotCooCoo/feat/no-op-undo-menu-transforms
Object: Lock, Transform Each, Perspective and other commands that change nothing record no undo step
2026-10-12 00:57:36 +07:00
dotCooCoo f769f4ea5d Tests: the envelope export test ignores the PDF's XMP dates and file ID
`enveloped_type_exports_distorted_in_every_format` exports each format
twice and compares the two files without the lines that contain
`CreationDate` or `ModDate`. Two other lines of the PDF also change
with the time it was written. The XMP packet is one line that holds
`xmp:CreateDate`, `xmp:ModifyDate` and the file ID, and the trailer
holds the file ID as `/ID`. The file ID is a hash that krilla computes
over the bytes written before it, which include the dates. When the
two PDF exports were written in different seconds, these two lines
differed, and the test failed at "pdf exports are repeatable".

The comparison now also drops the lines that contain `xmp:CreateDate`,
`xmp:ModifyDate` or `/ID[`. A new check writes the PDF that
`document.export` writes, with its creation time set to Unix seconds 0
and then 1, and asserts that the comparison's filter leaves the two
files equal.
2026-10-12 00:35:12 +07:00
dotCooCoo d875efd6e1 Tests: the command sweeps and import fuzz tests fail when a command panics
The engine's guard turns a panic inside a command into
EngineError::Internal and rolls the document back. The panic checks in
command_sweep.rs counted that error like any other error. They include
probe, which the empty-params, junk-params and structured-junk sweeps
call. The tests in import_fuzz.rs that open, place or paste a file
through a command counted it the same way. None of them reported the
panic.

Each of these checks now passes the command's result through no_panic,
which the library file tests in import_fuzz.rs already use, and the
change adds a copy of it to command_sweep.rs. A case whose command
panics now fails. The panic from no_panic reads "<command> panicked:
<message>", and the checks that print a panic's message show it. Each
check handles any other error as before.
2026-10-12 00:35:12 +07:00
bflatastic 99959e4b21 Czech: drop the Set Key Object row, as the other catalogs do 2026-10-12 00:35:12 +07:00
dotCooCoo 58af33aa1d Palette: the entry that cancels the key object reads Cancel Key Object
The command palette listed select.key as Set Key Object. A pick runs
the command without an id, which cancels the key object (#541).

select.key is now labeled Cancel Key Object, the label of the Align
panel menu item that also cancels the key object. The palette lists
it once, and engine.commands reports the new label. The params text
and docs/mcp.md say that select.key {id} makes id the key object, and
that select.key {}, or an id that names no selected object, cancels
it. The Set Key Object rows are removed from the 10 catalogs that
had one.

Docs: the select.key params text, docs/mcp.md,
docs/target-app-parity.md.

# Conflicts:
#	crates/engine/src/cmd/select.rs
#	docs/mcp.md
#	docs/target-app-parity.md
2026-10-12 00:34:58 +07:00
bflatastic 4ab69b877e Tests and Czech: select.key's new error is translated in cs, and the select tool test sets a shared appearance 2026-10-12 00:34:57 +07:00
BFlat 806b1a6972 Merge pull request #1225 from haranishi/fix/issue-1197-compound-attributes
fix: 複合パスの外観と塗り規則を修正する #1197
2026-10-12 00:28:12 +07:00
dotCooCoo 845aa6848f MCP: select.key checks its id, and agents can read the key object
Agents could set and clear the key object but not read it:
document.inspect, the control channel's ui.pointer reply and the
headless pointer_gesture reply left it out, and select.key returned
null. select.key with an id that was not selected, or with a value
that is not an id, such as "x", cleared the key without an error. A
compound-path member or an object inside a selected group could become
the key.

select.key now checks its id as select.set, select.add and
select.toggle do (#784). It returns an error for an id that is not a
selected top-level object and leaves the key as it was. Layers,
compound-path members and objects inside another selected object are
not top-level objects. select.key {id: null} used to clear the key and
is now an error; select.key {} still clears it. select.key returns
{key}, and document.inspect and both pointer replies include the key
object as "key".

In isolation mode a Selection-tool click can pick an object that
select.key does not accept as the key: a sublayer inside an isolated
layer, a member inside an isolated compound path, or an object in an
isolated group that is also selected. The tool no longer sends
select.key {id} for such an object. When the object is already the
key, the tool still sends select.key {}, which clears the key.

A recorded action whose select.key step names an object that is not a
selected top-level object when the action plays now fails and is
rolled back, as an action with a select.set step naming a deleted
object has failed since #784.

Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
2026-10-12 00:16:23 +07:00
dotCooCoo 208195765c Align: Align to Key Object makes the topmost selected object the key
With two or more objects selected and no key object, a click on Align
to Key Object in the Align panel stored Key as the Align To choice and
set no key. The align buttons of the Align panel, the Control bar and
the Properties panel then ran object.align with to: "key", which fails
with "no key object". Distribute Spacing sent the panel's spacing
value and kept the leftmost or top object in place. The stored Key
stayed until Align to Selection, Align to Artboard or Cancel Key
Object was chosen.

The button now runs select.key on the topmost selected object, the
last in paint order, whatever order the objects were selected in, and
the canvas draws that object with the key-object outline. The align
buttons in all three places and Distribute Spacing then use that key,
and Cancel Key Object clears it. A key that is already set stays the
key. With fewer than two objects selected the button is disabled, as
the Distribute buttons are. The button stores no choice, so once the
key is cleared by Cancel Key Object, by a click on the key object or
by another selection, Align To shows the stored Selection or Artboard
choice again. Cancel Key Object no longer sets that choice to
Selection.

Docs: docs/target-app-parity.md.
2026-10-12 00:16:22 +07:00
dotCooCoo 138d9499a7 Shape Builder: a line's loose end past a crossing no longer runs out and back in most cases
Since #1035 moved linesweeper to 0.5, the sweep can cut the paths
through one crossing at points more than its tolerance (eps) apart.
The planar map merged a point only into a vertex within eps of it, so
the two passes of an open path could end on two different vertices,
and it kept them as two edges with different ends. Past the crossing,
shape_builder then returned a line's loose end as one piece that runs
out to the line's end and back, and an extra piece of the line it
crosses, about 1.4e-6 long. In live_paint, the face around the loose
end ran out along it and back, and FaceMerger::merge of the faces
returned a second subpath along it. pieces_cut_out_of_their_inputs
fails on main for such an input.

The map now joins the ends of pieces whose middles and ends lie within
16 eps of each other. The two passes then share their ends and become
one edge. The new test is the failing input proptest saved for the
property. Lines that slope close to -1/32 in the document are nearly
level in the sweep's frame, and the change does not cover all of them.
2026-10-12 00:16:22 +07:00
dotCooCoo b1f3c819b4 Part of #394: art moved or duplicated with an artboard follows Transform Pattern Tiles
artboard.move with moveArt (the Artboard tool's drag of one artboard or
several and, with copy, its Alt-drag), artboard.duplicate with art and
artboard.rearrange with moveArtwork moved the art, or its copies,
without their pattern tiles, whatever General › Transform Pattern Tiles
was set to. They now follow the preference as the transforms and
artboard.setProps do: with it on, pattern fills and strokes move with
the art. Each also takes a `patterns` param for one call. When the art
uses a pattern, the journal records the choice, and a recorded action
plays back with it.

Docs: docs/mcp.md (transformPatternTiles, artboard copies),
docs/target-app-parity.md.

# Conflicts:
#	crates/engine/src/cmd/menucmds.rs
2026-10-12 00:16:21 +07:00
dotCooCoo cf583304d1 Object: Lock, Transform Each, Perspective and other commands that change nothing record no undo step
These commands edited the document even when nothing changed, so the
call recorded an undo step, emptied Redo and marked the document
modified:

- All Artwork Above and Other Layers (Object › Lock and
  Object › Hide) with nothing to lock or hide: no object above the
  selection, no other layer, or every one already locked (Lock) or
  hidden (Hide).
- Transform Each with no object to transform, and without Copy when
  both scales are 100% and there is no move, rotation or reflection.
- Rearrange All Artboards when no artboard moves, or with no artboard.
- stroke.widthPoint.set and stroke.widthPoint.copy when the profile
  comes out as it was, and stroke.widthProfile.set with the profile the
  strokes have already.
- Liquify when the stroke reshapes no path, and Puppet Warp given the
  pins the art holds already (rest: true), or without rest when every
  pin stays where it is and turns the art by 0° or is free.
- Define Perspective Grid with the grid the document has, Attach to
  Active Plane with no object, Release with Perspective on art that is
  not in perspective, Move Plane to where the plane is, without copies,
  and Move Plane to Match Object when the plane is there and active
  already.
- Move, Nudge and Transform in Perspective without Copy when no object
  moves (1e-9 pt or less along the plane or its normal, Snap to Grid
  included, or the identity), and Transform Again after one.

These commands now return without editing the document. The replies
have the same form as before. Transform Each with Copy and no object
to transform no longer empties the selection. Puppet Warp without rest,
with every pin where it is and turning the art by 0° or free, no longer
turns a live shape into a plain path. All Artwork Above still deselects
selected objects that are hidden or locked. Width Point counts a change
of 1e-9 or less in a point's place or width factor (its width over half
the stroke weight) as none. OK in Width Point Edit with the widths it
shows, with or without Adjust Adjoining Width Points, records no undo
step. After a move in perspective that moved nothing, Transform Again
repeats the identity.

One line in the evidence list of docs/target-app-parity.md names the
commands.
2026-10-12 00:15:59 +07:00
Ikki Nishihara fcb1d81ba6 fix: 複合パスの外観と塗り規則を修正する #1197 2026-10-12 00:12:52 +07:00
BFlat bab78767ca Merge pull request #1240 from storytold/docs/perf-1173
Docs: record the large-document performance work (#1173)
2026-10-11 23:58:13 +07:00
bflatastic 777def98b7 Docs: record the large-document performance work (#1173) 2026-10-11 23:57:49 +07:00
BFlat 0e36a44137 Merge pull request #1173 from arhammusheer/perf/editing-at-scale
perf: large documents (#1172): big selections 100×+, live effects 18×, Pathfinder 11–20×, 41% less undo memory
2026-10-11 23:56:38 +07:00
arhammusheerandClaude Opus 5.5 0724aafbb6 #1172: share an object's appearance with its undo history until it changes
Every edit of an object copied its whole appearance (its fills, strokes and effects, about
540 bytes for a basic object) into the new node, though a move leaves it as it is. Over 45
whole-selection moves of 10,000 paths, undo history grew by 589 MB; now 349 MB (41 % less), and
a nudge or a drag of the selection takes 12 ms instead of 15.

- Node::appearance is a vectorcraft_doc::Shared<Appearance>: copy-on-write. A clone shares it,
  and the first change through DerefMut copies it (Arc::make_mut). Reads, writes, equality (also
  against a plain Appearance) and the saved form are those of the Appearance itself, so call
  sites read and change it as before. Sites that build or assign a whole appearance add .into(),
  and the few that need a plain copy take (*n.appearance).clone().
- Node::transform changes the appearance only when it has gradients to transform, and pattern
  paints are transformed only when there are some: a move no longer copies an appearance it
  doesn't change.

The workspace suite (5,301 tests) and clippy pass with the change; the doc and engine suites
pass again after the transform guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:27:21 +07:00
arhammusheerandClaude Opus 5.5 7652439b40 #1172: Pathfinder Outline 19x, SVG export 1.5x and open 1.2x faster, same output
At 50,000 paths unless noted, against main:

- Pathfinder Outline (300 paths): 145 → 7.5 ms. Each segment was tested against every junction
  of the arrangement, and each chain against every chain kept so far. Now a segment tests only
  the junctions inside its control box (junctions are sorted by x; a point within reach of a
  curve is within reach of that box), with the same nearest-point test. Coincident chains are
  found through a grid on their midpoints, taking the first match in the same order a scan does.
- export SVG: 267 → 176 ms. Path data is written straight into one string (`push_num`, the
  same digits and trimming as `fmt_num`) instead of a string per number, point and segment.
- open .vectorcraft: 239 → 195 ms. The JSON was parsed once only to find where it ends:
  - A file with no NUL has no data after it.
  - Otherwise the marker (which holds the first NUL, as JSON can't) is taken when it directly
    follows the JSON's last byte, as the writer puts it.
  - Anything else takes the old parse.

`perf --deep` prints output hashes of an SVG export and a save → open → save round trip; main
and this branch print the same ones. pathfinder_pinned (Outline included) passes, and so do the
pathops, format and svg suites (345 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
arhammusheerandClaude Opus 5.5 4c5809302d #1172: Pathfinder Trim, Merge and Crop 9-12x faster, same output
Trim, Merge and Crop walk the arrangement once per input shape, and every walk tested each edge
side with a coverage mask built from a winding vector with one slot per input. On 300 paths,
against main:

- Trim: 362 → 31 ms
- Merge: 366 → 31 ms
- Crop: 303 → 28 ms
- Unite, Intersect, Exclude, Minus Front and Minus Back: unchanged or a little faster

- Multi, the arrangement's winding, keeps only the non-zero windings as sorted (input, winding)
  pairs, added in place. A face is covered by a few inputs, so a test reads a few entries
  instead of one per input. The winding arithmetic is unchanged.
- Arrangement::covered lists the inputs that cover a face (the true entries of its coverage
  mask). Trim, Merge, Crop, Minus Front, Minus Back, Intersect and Exclude test that list; each
  test's old mask form is in a comment beside it. mask() still builds the same mask for the rest.

pathfinder_pinned hashes the output of all ten operations on 2, 5, 12 and 30 overlapping
ellipses, rectangles and stars (even-odd and non-zero, shared keys for Merge). The 40 hashes were
computed with main's dense windings, and the new code gives every one of them. A wrong Crop test
fails it. The pathops suite (103 tests) passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
arhammusheerandClaude Opus 5.5 2bb7646a39 #1172: make the id cache cheap to rebuild, and walk on a stale path
The previous commit rebuilt the cache as soon as a cached path went stale, and built it as one
Vec path per node, hashed with SipHash. An edit that removes many objects one by one
(Pathfinder Unite of 1,000 paths) went stale on every removal, and Unite took 105 ms instead of
42 ms.

- The cache is now one flat table of (parent entry, index among siblings) with a one-multiply
  id hasher, so a rebuild is about one walk and allocates nothing per node.
- A stale path walks, as an id the cache doesn't have does, and rebuilds only after a few.
  Either way a lookup stays within a small factor of a walk.

At 1,000 paths, Unite takes 45 ms against main's 42. At 100,000 paths the gains of the cache hold:
hit test 1.7 → 0.36 ms, handle drag 6.5 → 2.7 ms per move. The cached-lookup tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
arhammusheerandClaude Opus 5.5 7d79a32748 #1172: look nodes up by id through a checked cache of their tree paths
A lookup by id walked the tree, so every edit, click and pointer move of a single object cost
time in proportion to the whole document. At 100,000 paths:

- hit test, per click: 1.85 → 0.35 ms
- handle drag, 5,000-anchor path, per move: 6.5 → 2.6 ms
- segment drag, per move: 5.8 → 2.7 ms

Document keeps every node's index path in a cache (id_index, never saved). The tree is edited
through public fields in many places, so the cache isn't kept in step with it; it is checked
instead. A cached path is used only while the node at that path still has the id, a check as deep
as the tree. A path that doesn't check out rebuilds the cache, and an id it doesn't have walks,
as before. A rebuild costs a few walks, so it happens only after a few such misses, which keeps
any edit pattern within a small factor of the walk. Ids that appear more than once are never
cached, so they keep their first place in tree order. index_paths uses the cache for small
batches and one walk for large ones.

The one case that answers differently from a walk: a second node with an existing id put in the
tree after the cache was built, earlier in tree order. The lookup then returns the original node,
also one with that id. The app doesn't make such nodes; damaged files with duplicates are cached
afresh when opened.

cached_lookups_follow_any_edit_of_the_tree checks index_path, node and index_paths against a
walk after inserting at the front, removing, swapping, moving between layers, deleting a layer,
and in a clone sharing the cache. Using cached paths without the check fails it. The doc and
engine suites (2,002 tests) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
arhammusheerandClaude Opus 5.5 f8064ba3ce #1172: rasterize with up to 8 threads instead of 4
The automatic rasterizer thread count was one per core up to 4. On a 10-core Apple M2 Pro, 8
workers are faster (9 are slower again), and vello_cpu draws the same pixels with any number:

- fit page, 50,000 paths: 66.7 → 56.6 ms; after a pan 66.4 → 51.2 ms
- 400 % zoom, 50,000 paths: 17.8 → 15.7 ms (back under its 16 ms budget)
- fit page, 100,000 paths: 137 → 105 ms; 400 % zoom 33.0 → 25.8 ms

Machines with 5 cores or fewer keep their count, and Preferences → Performance and
VECTORCRAFT_RENDER_THREADS still override it. pixels_do_not_depend_on_the_thread_count renders
a page of live effects and 400 overlapping translucent objects with 0, 1, 4 and 8 workers and
gets the same pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
arhammusheerandClaude Opus 5.5 add27cb8fc #1172: keep live-effect geometry across frames
A node with live geometry effects (Offset Path, Outline Stroke, Warp, Transform, Roughen…) had
them computed again on every frame: Offset Path's curve offsetting and boolean sweep for every
object, every time the canvas redrew. Rendering 2,000 paths that each have Offset Path, warm:
175 → 11.8 ms.

The renderer keeps each node's effected geometry, and each appearance item's own geometry
effects on it, keyed by the node's Arc identity like its other per-node caches. The entry keeps
the Arc alive, so a hit is exact and an edited object (a new node) gets new geometry. Only the
node draw_arc is drawing uses the cache: its path comes from the node alone, so its effects give
the same result every frame. Entries not drawn for three frames are swept like the rest.

cached_effect_geometry_draws_the_same_pixels renders a page of Offset Path, Roughen + Drop
Shadow, Warp + Outline Stroke and Transform objects (with item-level Offset Path) cold, warm,
after a pan and with a fresh renderer, and gets the same pixels each time; after an edit, the
warm renderer draws what a fresh one does. Keying the cache by node id instead of identity fails
it. The render suite (216 tests, golden images included) passes unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
arhammusheerandClaude Opus 5.5 4b260c364d #1172: edit a large selection in time linear in the document, not quadratic
Every lookup by id walked the whole tree, and the edit path did one per selected id, so editing
a selection of n objects cost O(n²). Measured with the new `perf --deep` rows at 25,000 paths:

- Select All: 458 → 3.5 ms
- nudge the selection: 3,517 → 34 ms
- drag it with the Selection tool, per pointer move: 3,413 → 35 ms
- undo / redo: 407 → 3.1 ms

- vectorcraft_doc: Document::index_paths (every id's index path, in one walk; the first in tree
  order for a duplicated id, as index_path), node_at, node_mut_at (copy-on-write, as node_mut)
  and ancestry_at.
- apply_transform, roots_of, transform_patterns, doc_sane, Selection::prune and
  Document::paint_order look their ids up with one walk instead of one each.
- Selection::set dedups with a hash set instead of Vec::contains per id.
- vectorcraft-cli perf --deep: tracked rows for editing a whole selection (select, nudge, drag,
  undo, redo), live effects, every Pathfinder operation, typing in a long story, and memory.

tests_lookups checks the batched lookups against the one-id ones on nested trees with missing and
duplicated ids; storing the last duplicate instead of the first fails it. The doc and engine
suites (2,000 tests) pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-11 23:26:53 +07:00
BFlat d1a6926f63 Merge pull request #1200 from atoslins/linux-compositor-gpu
Linux: Automatic draws on the GPU the Wayland compositor draws with
2026-10-11 22:20:13 +07:00
BFlat 980a3f651c Merge pull request #1201 from zakariayacine/type-digits
Type: Digits — Default, Arabic, Hindi and Farsi numeral systems (Character panel menu, Middle Eastern options)
2026-10-11 22:19:56 +07:00
BFlat 15675760c5 Merge pull request #1189 from Losminek/cs-pr
i18n: complete the Czech (cs) catalog: panels, dialogs, plurals and messages
2026-10-11 22:19:42 +07:00
BFlat 4b311f741e Merge pull request #1205 from WladmirJunior/i18n/pt-br-complete
pt-BR: translate the remaining UI labels and engine messages
2026-10-11 22:19:32 +07:00
BFlat 77aa384bca Merge pull request #1206 from DisasterAreaDesigns/fix/1102-paragraph-space-in-leading
Part of #1102: type from the text document keeps its line spacing across paragraphs
2026-10-11 22:19:17 +07:00
BFlat 1c44fb766f Merge pull request #1207 from p0lyg0n/fix/ai-font-postscript-name
EPS/.ai import: a font is found by its exact PostScript name before the name is split
2026-10-11 22:19:00 +07:00
BFlat be55c5457f Merge pull request #1208 from p0lyg0n/fix/ai-copied-type-frames
.ai import: copied area type and type on a path keep their own place
2026-10-11 22:18:42 +07:00
bflatastic 6a214253a8 Review: rewrap the adapter order docs (#1200) 2026-10-11 22:18:10 +07:00
bflatastic cfe0a779f0 Review: Czech rows for Digits (#1201)
Czech is a complete catalog now (#1189), so it needs the new labels.
2026-10-11 22:18:06 +07:00
Atos Lins c1a3d04e9e Contributors: add atoslins (Atos Lins) 2026-10-11 22:18:06 +07:00
Atos Lins 9c40a2643f Linux: Automatic draws on the GPU the Wayland compositor draws with
On a hybrid laptop whose Wayland compositor runs on the discrete GPU
(Hyprland with AQ_DRM_DEVICES naming the NVIDIA card first), the built-in
panel still hangs off the integrated GPU, so the display rule put it first,
and the window stayed empty: the compositor took the frames and showed
nothing, with no error, so neither the restart on a failed adapter (#502)
nor the first-frame watch (#964) noticed.

Which GPU drives a display stands in for which GPU the compositor draws
with. Under Wayland the compositor says so itself: the main_device of its
linux-dmabuf feedback (version 4), the device it imports client buffers on,
which Mesa's Vulkan device-select layer reads for the same purpose.
compositor_gpu.rs asks for it on a short-lived connection, maps the dev_t
to PCI ids through /sys/dev/char, and under Automatic adapter_order ranks
that adapter ahead of the display GPUs. No answer (X11, an older compositor,
a GPU no adapter matches) leaves the existing rules in charge; a user's own
choice, WGPU_ADAPTER_NAME and the restart skip list still come first.

wayland-client and wayland-protocols are already in the tree through winit;
they become direct Linux-only dependencies.
2026-10-11 22:18:06 +07:00
bflatastic d19fd80390 Review: progress log for the complete Czech catalog (#1189) 2026-10-11 22:18:01 +07:00
zakariayacineandClaude Fable 5.1 16af3cc3fa Type: Digits — Default, Arabic, Hindi and Farsi numeral systems
Adds the Character panel menu's Digits option (with the Middle Eastern /
Indic options, beside Paragraph Direction in the Paragraph panel) as a
character attribute: CharStyle::digits, saved only when set, with
text.setFormat {digits} and text.setRangeStyle {id, start, end, digits}
("default" | "arabic" | "hindi" | "farsi"; a bad value is an error).

Shaping draws every digit of the Western, Arabic-Indic (U+0660–U+0669)
and Eastern Arabic-Indic (U+06F0–U+06F9) sets in the chosen system,
whatever was typed, before font fallback, so a font without the
Arabic-Indic digits gets a fallback for them. The text, its clusters,
the caret, selection and search stay as typed, as with All Caps; SVG
export writes the digits as drawn.

Catalog rows for Digits, Arabic, Hindi and Farsi in the ten translated
languages. Docs: docs/mcp.md (Digits), type-parity.md (digit types
done; Character Direction, kashidas, composers and the split caret
still missing), target-app-parity.md and gaps.md.

Behaviour from the public documentation (a character-level option with
the values Arabic, Hindi and Farsi in the Character panel menu):
https://helpx.adobe.com/indesign/desktop/language-and-proofing/arabic-and-hebrew/choose-digit-types.html

Tests: doc (the mapping and serialization), text (glyphs of "page 3" in
Hindi/Farsi equal the typed Arabic-Indic digits' on the typed clusters;
skips without a font covering the three sets), engine (setFormat,
setRangeStyle, undo, error), svg (digits written as drawn), ui-egui
(the menu shows with the Indic options).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 22:18:01 +07:00
LosminekandClaude Opus 5.5 15beddbb74 i18n (cs): keep the language name "English", translate "List Folder"
The language switcher shows every language in its own name in every UI
language (language_and_appearance_are_in_the_app_menu), so "English" stays
"English" like in the other catalogs. "List Folder" is the file.list command
that arrived with #1181 and is a menu string the complete catalogs translate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:56 +07:00
LosminekandClaude Opus 5.5 d73e6a7648 contributors: add my own people.toml entry
Own entry only, as AGENTS.md describes: credited by the name on my public
GitHub profile (sync_github_name), otherwise by @username.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:55 +07:00
LosminekandClaude Opus 5.5 4a2b2bb36a i18n (cs): terminology review fixes
Changes from an independent review pass over the Czech catalog. Short keys
shared between a menu and a dialog got the wording that fits both places
(All = "Vše", Add = "Přidat", Type: = "Typ:", Center = "Střed", Edge =
"Okraj", Advanced = "Rozšířené"); sentences that built a plural from a bare
count now read "Počet ...: {count}"; "Offset Path" is "Odsadit cestu", the
New... button is named consistently in the preset hints, "resolve" is not
translated as "rozlišit", and the rendering intent is "Záměr vykreslení".

Strings that need a context in the code to be right everywhere (Scale as a
dialog title, Random/Uniform as check boxes, Subset fonts below) are left as
they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:55 +07:00
LosminekandClaude Opus 5.5 d906a59287 i18n: Czech Expand reads "rozložit" in every label
Object › Expand, Expand Appearance and the rest of the catalog say
"Rozložit"; three older rows said "rozšířit" (widen) for the same
command: Expand Shaper Group and the two Revolve notes about expanding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:55 +07:00
LosminekandClaude Opus 5.5 a59d8533b7 i18n: Czech joins the complete-message languages; Czech reads as Czech
Czech is in COMPLETE_MESSAGES, so a new status or error message needs a
cs.tsv row too. czech_reads_as_czech checks the vocabulary (Nástroj
Pracovní plocha, Vzorník, Operace s cestami, Tah, Výplň), the three
plural forms of a layer count, the axis context and a nested message;
czech_plurals_have_three_forms now also runs every @plural row through
trn for one, few and other counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:55 +07:00
LosminekandClaude Opus 5.5 b9f4d9bd24 i18n: complete the Czech (cs) catalog: panels, dialogs, plurals and messages
The Czech catalog covered every menu label (726 rows); panels, dialogs,
tooltips and status messages fell back to English. It now has a row for
every key of the complete catalogs: every menu string and tl! literal,
the 13 plurals in one/few/other form, the axis/effect/lighting/theme
contexts and every @msg status and error message (3,404 rows added,
4,130 in all). The existing rows are kept as they were; the header says
how the new ones were written and lists the vocabulary.

With the catalog complete, Czech sets complete_menus, so the coverage
tests now hold it to every menu string and tl! literal.

Written clean-room from the meaning of the English text, drafted with AI
assistance; not yet reviewed by a native speaker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:55 +07:00
bflatastic 44eddacc23 Review: progress log for the pt-BR catalog (#1205) 2026-10-11 22:17:54 +07:00
bflatastic d3f8ee14cc Review: leading of wrapped area type keeps the line distance (#1206)
Lines that wrap don't pair up with the text's breaks, so taking a
paragraph's spacing off the first pair could shrink ordinary leading.
2026-10-11 22:17:50 +07:00
Wladmir Júnior e60487755b pt-BR: translate the remaining UI labels and engine messages 2026-10-11 22:17:50 +07:00
bflatastic eb20ee1fbc Review: progress log for exact PostScript font names (#1207) 2026-10-11 22:17:45 +07:00
Matthew FarrowandClaude Sonnet 5.5 77b247dea5 Part of #1102: type from the text document keeps its line spacing across paragraphs
The leading of a text object made from the file's text document was the
distance between the first two lines of the file's own layout. Those lines
are where the paragraph spacing has put them, and the text object applies
that spacing again, so paragraphs with a space after (or before) came in with
their lines too close (negative space) or too far apart (positive).

The leading is now that distance less the space after the paragraph before
and the space before the paragraph after; two lines of one paragraph have
none between them.

Tests on synthetic stories: negative and positive spacing, a frame scale,
line breaks inside a paragraph, a single line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:17:45 +07:00
Hiroshi InukaiandClaude Opus 5.5 0e5e83cf5c EPS/.ai import: a font is found by its exact PostScript name before the name is split
The private data a .ai file opens with names fonts by PostScript name, and
the importer split the name at its first hyphen: rounded-x-mplus-1c-black
became the family "rounded", style "x-mplus-1c-black", which no font has,
so the type fell back to Source Sans 3 although Rounded-X M+ 1c is
installed. An installed or bundled face of that exact PostScript name now
gives its own family and style; other names split as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:40 +07:00
bflatastic 66fd1b78a2 Review: reuse the importer's find and number helpers (#1208) 2026-10-11 22:17:39 +07:00
Hiroshi InukaiandClaude Opus 5.5 996111b179 .ai import: copied area type and type on a path keep their own place
Read through its editing data, a .ai put the copies of area type and type
on a path on top of their original: the text document's frame of a copied
object keeps the original's path (with a matrix of its own that isn't
understood yet). The object's `/Art` path, which the layers' data keeps
for each text object, is where the file places it: area type and type on
a path now take it as their frame or path, by story and frame.

On a file of cards made by copying one, the type laid out from the text
document now lands on each card (515 text objects where its PDF part has
them, against 467, many on one card, before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:32 +07:00
BFlat 553f83e5bf Merge pull request #1217 from storytold/fix/955-move-snaps-grabbed-point
#955: a move grabbed by an anchor lands it on another anchor
2026-10-11 22:09:02 +07:00
bflatastic 30ddaa78ff #955: a move grabbed by an anchor lands it on another anchor
With Smart Guides on, a selection moved by the Selection tool only lined
its bounds up with the other art: grabbed by an anchor and dropped near
another object's anchor, it could stop a fraction of a point beside it
where an edge lined up instead. When the grabbed point comes within the
snapping tolerance of an anchor or a centre, it now lands on it, as
Smart Guides' anchor snapping does.
2026-10-11 22:08:30 +07:00
BFlat f455e362aa Merge pull request #1216 from storytold/fix/1180-align-anchors
#1180: Align aligns the selected anchor points
2026-10-11 22:08:13 +07:00
bflatastic 06d3e52c24 #1180: Align aligns the selected anchor points
With anchor points selected by Direct Selection, the Align buttons moved
the whole path, so two points couldn't be lined up. As in Illustrator,
they now align the selected points: to the points' own bounds (Align To
Selection) or to the artboard. A path selected whole still aligns as an
object.
2026-10-11 22:08:01 +07:00
BFlat 8fbb09bf4e Merge pull request #1215 from storytold/fix/1194-dx12-fxc
#1194: Direct3D 12 shaders compile with FXC
2026-10-11 22:03:50 +07:00
bflatastic 21459f4df4 #1194: Direct3D 12 shaders compile with FXC
wgpu's default DX12 shader compiler loads whichever dxcompiler.dll the
DLL search path finds first; VectorCraft ships none, so it was another
program's, and one without its dxil.dll warns that the DXIL is unsigned.
wgpu takes the warning for a compile error, the device is lost before
the first frame, and the app falls back to OpenGL. FXC (d3dcompiler_47,
part of Windows) compiles everything the window needs, as LightCraft
does since its #484; WGPU_DX12_COMPILER still picks another.
2026-10-11 22:03:40 +07:00
BFlat 9e3aa73819 Merge pull request #1214 from storytold/test/idle-link-scan-flake
Test: the idle place test waits for the link scan in real time
2026-10-11 22:03:16 +07:00
bflatastic 6f4049494d Test: the idle place test waits for the link scan in real time
tests_place::a_file_placed_while_idle_follows_its_changes failed at
random in CI (twice on 2026-10-11): while the link scan ran on its
worker thread, the test's clock moved on by whole simulated seconds per
5 ms of real time, so on a busy machine the 10 simulated seconds ran out
before the scan had read the changed file. The clock now waits for the
scan (up to 20 real seconds).
2026-10-11 22:02:50 +07:00
BFlat 29bad213f8 Merge pull request #1213 from storytold/fix/1197-compound-holes
#1197: Make Compound Path cuts holes where paths overlap
2026-10-11 22:02:36 +07:00
bflatastic 7f35522b15 #1197: Make Compound Path cuts holes where paths overlap
Make Compound Path kept each path's direction under the non-zero fill
rule, so a shape drawn inside or across another, running the same way,
left the overlap filled instead of making a hole. The paths in front of
the backmost one now run against it, so where they overlap it they cut
holes, as users expect from a compound path.
2026-10-11 21:32:23 +07:00
BFlat 3b60d0ceb6 Merge pull request #1204 from storytold/ci/craft-fonts-pin
Release: embed the current craft-fonts (Thai, Arabic, Chinese)
2026-10-11 20:57:47 +07:00
bflatasticandClaude Opus 5.5 947b294f87 Release: embed the current craft-fonts (Thai, Arabic, Chinese)
Releases pinned craft-fonts at its first commit, with the Japanese fonts
only. The pin moves to its current main, as PdfCraft, LightCraft and
WordCraft have moved theirs: desktop builds embed Noto Sans Thai, Noto
Sans Arabic and Noto Sans CJK SC too (the web build still embeds only
the UI font and fetches Thai and Arabic beside the wasm). Packages
already copy every embedded font's licence.

The Thai loanword test no longer assumes the bundled fonts' widths: it
checks that no line ends inside โปรแกรม, whichever Thai font sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:47:46 +07:00
BFlat 434b47d9c4 Merge pull request #1195 from storytold/fix/thai-word-navigation
Type: the caret and double-click take Thai word by word
2026-10-11 20:07:03 +07:00
bflatasticandClaude Opus 5.5 a08ca07c69 Type: the caret and double-click take Thai word by word
Ctrl/Alt+arrows and double-click treated a run of Thai (Lao, Khmer,
Myanmar) without spaces as one word, and stopped at a word's tone and
vowel marks, which didn't count as part of a word. They now stop at the
word boundaries the line segmenter finds in those scripts (shared with
line breaking, so no more data ships), and combining marks count as
part of a word, also for combining accents in other scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:06:39 +07:00
BFlat d17c07f343 Merge pull request #1191 from storytold/fix/thai-craft-font
Type: Noto Sans Thai from craft-fonts is the Thai fallback, on the web too
2026-10-11 19:52:51 +07:00
bflatasticandClaude Opus 5.5 70e99987e9 Type: Noto Sans Thai from craft-fonts is the Thai fallback, on the web too
The web build has no installed fonts, so Thai there showed as empty
boxes. With a craft-fonts checkout that has Noto Sans Thai, its Thai
font joins the document font database after the Arabic ones (the
fallback for Thai where no Thai font is installed) and the web build
fetches it before the app starts, as it does Noto Sans Arabic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:52:08 +07:00
BFlat 7a85f6050d Merge pull request #1190 from storytold/fix/thai-font-filter
Font menu: a Thai script filter
2026-10-11 19:51:53 +07:00
bflatasticandClaude Opus 5.5 cc2f45b129 Font menu: a Thai script filter
The font menu's script filter offered Japanese and Latin only, so Thai
fonts had to be found by name among every installed family. It now has
Thai too: the families whose character map has Thai. Latin lists the
families with neither Japanese nor Thai.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:51:32 +07:00
BFlat fe8af48fa7 Merge pull request #1188 from storytold/fix/thai-line-breaks
Type: Thai, Lao, Khmer and Myanmar lines break between words
2026-10-11 19:33:29 +07:00
bflatasticandClaude Opus 5.5 5a855971aa Type: Thai, Lao, Khmer and Myanmar lines break between words
These scripts don't separate words with spaces, and lines only broke at
spaces, hyphens and CJK characters, so a Thai paragraph in area type ran
on to the next space (or was cut wherever it overflowed). Each
paragraph's glyphs now carry the word ends ICU4X's line segmenter finds
in text of those scripts (its dictionaries on the desktop, its far
smaller LSTM models on the web), and a line may break after them as
after a space. Other text keeps its own breaks, and clusters (a
consonant with its vowel and tone marks) still never split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:31:37 +07:00
BFlat 36494d0385 Merge pull request #1187 from storytold/docs/type-parity-status
Docs: type parity has one status line again
2026-10-11 19:30:25 +07:00
bflatasticandClaude Opus 5.5 2cfee9b99b Docs: type parity has one status line again
Two branches' status lines both landed in a merge; they are one line
again, naming both changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:30:12 +07:00
BFlat 90bd91986a Merge pull request #1186 from storytold/fix/thai-caret-clusters
Type: the caret steps over whole clusters (Thai marks, accents, emoji)
2026-10-11 19:29:37 +07:00
bflatasticandClaude Opus 5.5 3fb51869ac Type: the caret steps over whole clusters (Thai marks, accents, emoji)
The Type tool's Left and Right arrows and Delete moved one code point at
a time, so the caret could stop between a Thai consonant and its vowel
or tone marks (or inside an accented letter or emoji sequence), where
typing or deleting splits the cluster. They now step over extended
grapheme clusters. Backspace still takes back one character at a time,
as Thai typists expect for a mistyped tone mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:29:16 +07:00
BFlat 436d7ecdd3 Merge pull request #1185 from storytold/fix/thai-font-fallback
Type: Thai falls back to a font made for Thai
2026-10-11 19:28:58 +07:00
bflatasticandClaude Opus 5.5 fba3702103 Type: Thai falls back to a font made for Thai
Thai typed in a font without Thai glyphs (Source Sans 3, a Latin font)
took the first loaded face that had them, or the first installed file
that did, in path order: on one Windows machine a display face (Kanit)
rather than Leelawadee UI. Characters of Thai, Lao, Khmer and Myanmar
now try fonts made for their script first, installed ones loaded on
demand (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai,
the TLWG fonts for Thai), then the fallback as before. Latin beside
them keeps the font chosen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:42:31 +07:00
BFlat 7c7a7dcdc9 Merge pull request #1181 from storytold/fix/1163-file-list
#1163: file.list lists a folder for agents, held to the read root
2026-10-11 18:35:45 +07:00
bflatasticandClaude Opus 5.5 339b01f48b #1163: file.list lists a folder for agents, held to the read root
An agent driving VectorCraft over the confined MCP server had no way to
see which files it was given, so it guessed names. `file.list {path}`
lists a folder's files (with their sizes) and folders by name, held to
the automation read root like every read; links are listed as links and
not followed. At most 5000 entries, with `truncated` saying when there
were more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:23 +07:00
BFlat 6d07135456 Merge pull request #1179 from storytold/fix/1163-atomic-batch
#1163: command_batch can be one undo step
2026-10-11 18:22:58 +07:00
bflatasticandClaude Opus 5.5 ed80181d70 #1163: command_batch can be one undo step
An agent's build over MCP left one undo step per batch step (about 280
for one job). `command_batch` takes `atomic: true` (and an optional
`label` naming the step) to make the whole batch one undo step. It runs
the batch in an undo group, opened and closed by the new commands
`edit.undoGroup.begin {label?}` and `edit.undoGroup.end {cancel?}`, so
every backend (headless or the connected app) supports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:22:45 +07:00
BFlat 24eaf5e037 Merge pull request #1147 from KnottyDyes/squad/raster-effect-resolution-warning
PDF export: warn when raster effects render below the requested resolution
2026-10-11 18:18:02 +07:00
bflatasticandClaude Opus 5.5 4603ebec7a Review: report module beside rasterfx, ids checked without a tree walk, docs trimmed (#1147)
The report module moves to cmd/rasterfx/report.rs (the crate's foo.rs +
foo/ layout instead of #[path]). An object is the document's when its id
is below the document's next id, so building the report no longer walks
the whole document (and its symbols and patterns) on every PDF, SVG,
print and clipboard export. The parity docs say what changed in the
status line and one evidence bullet instead of a separate paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:17:25 +07:00
KnottyDyes 8d881cc0bc PDF export: warn when raster effects render below the requested resolution
The 64 million pixel cap can silently lower raster-effect resolution. Report each original object's id and name with requested and achieved ppi through the existing export warnings, retaining the lowest resolution across its images.

Keep the cap and rendering unchanged, cover capped and uncapped exports, and record the evidence without changing parity scores.
2026-10-11 18:17:25 +07:00
BFlat 2ecc83e700 Merge pull request #1178 from storytold/review/1157-followup
Review: house header for the root-argument integration test (#1157)
2026-10-11 18:17:11 +07:00
bflatasticandClaude Opus 5.5 731c30ff6d Review: house header for the root-argument integration test (#1157)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:16:43 +07:00
BFlat 71b7ae3d56 Merge pull request #1157 from piero-83/ll-root-preflight
Reject malformed desktop automation root arguments before startup
2026-10-11 18:15:54 +07:00
BFlat 6eaaccb717 Merge pull request #1148 from KnottyDyes/squad/new-art-paint-docs
MCP: drawing tools describe inherited paint and export text options
2026-10-11 18:15:10 +07:00
bflatasticandClaude Opus 5.5 805d30e284 Review: DXF outlineText note and new-art wording in the MCP docs (#1148)
DXF already outlines type unless preserve: editability, so say so next to
the outlineText flag; reword the new-art sentence and point to paint.default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:14:25 +07:00
KnottyDyes 4505ccb20d MCP: drawing tools describe inherited paint and export text options
Omitted paint fields keep the current appearance, so agents can get a black outline when they only set a fill. Document the initial white fill and 1 pt black stroke and recommend explicit paint for outline-free art.

Clarify SVG/SVGZ outlineText versus PDF options.advanced.outlineText and PDF's live-text default, noting DXF also accepts the top-level flag. Descriptions and documentation only; drawing and export behavior are unchanged.
2026-10-11 18:14:23 +07:00
BFlat 433c8d5aa7 Merge pull request #1177 from storytold/fix/1163-preview-artboard
#1163: MCP render_preview takes an artboard index
2026-10-11 18:13:36 +07:00
bflatasticandClaude Opus 5.5 c3719b2e79 #1163: MCP render_preview takes an artboard index
render_preview only rendered the first artboard, so an agent couldn't
look at pages 2 and on of a multi-artboard document without switching
to screenshot. It now takes an optional 0-based `artboard` (as
screenshot does) and refuses an index the document doesn't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:13:26 +07:00
BFlat 9aed6191d4 Merge pull request #1176 from storytold/fix/1163-release-subpaths
#1163: Release splits a path of several subpaths into one path each
2026-10-11 17:58:25 +07:00
bflatasticandClaude Opus 5.5 9b12fff215 #1163: Release splits a path of several subpaths into one path each
A path of several subpaths (an SVG `d` with several `M`s, PDF paths, a
path drawn in one go over MCP) is a compound path in Illustrator, and
Object › Compound Path › Release splits it into separate paths. Here
Release only acted on compound path objects, so such a path couldn't be
broken apart. Release now gives each subpath a path of its own, with the
path's paint, in stacking order; the first keeps the path's id. One undo
step, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:58:13 +07:00
BFlat e428f38767 Merge pull request #1175 from storytold/fix/1158-alt-click-shows-clicked-layer
#1158: Alt-click on a layer's eye or lock isolates that layer
2026-10-11 17:50:46 +07:00
bflatasticandClaude Opus 5.5 da735914b6 #1158: Alt-click on a layer's eye or lock isolates that layer
Hide Others, Lock Others and Outline Others left the layer they keep as
it was, so Alt-clicking the eye of a hidden layer hid everything and
Alt-clicking a locked layer's lock left it locked. As Illustrator's
Layers panel does, the layer clicked now shows (unlocks, previews) while
the others hide (lock, outline), in the same undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:50:35 +07:00
BFlat 2adf4465d6 Merge pull request #1174 from storytold/fix/1111-bend-blend-spine
#1111: a blend's spine bends into a curve
2026-10-11 17:49:59 +07:00
bflatasticandClaude Opus 5.5 d3c997329d #1111: a blend's spine bends into a curve
The Anchor Point tool drags any segment of a selected blend's spine into
a curve, straight ones too, and Direct Selection reshapes a curved spine
segment as it does a path's (a straight one it leaves, as on paths). Both
run the new `object.blend.spine.reshapeSegment`, which bends the segment
with the same math as `path.reshapeSegment`, now shared as
`SubPath::bend_segment`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:49:41 +07:00
BFlat 1637165600 Merge pull request #1171 from storytold/fix/1111-mesh-selection-grid
#1111: a selected gradient mesh shows its mesh lines and points
2026-10-11 17:36:34 +07:00
bflatasticandClaude Opus 5.5 1f5073e4d1 #1111: a selected gradient mesh shows its mesh lines and points
Only the Mesh tool drew a gradient mesh's grid, so with the Selection
or Direct Selection tool a selected mesh showed no outline and no
points, and its points couldn't be seen to be clicked. The selection
highlight now draws a gradient mesh's lines and points, as it already
did for mesh envelopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:36:23 +07:00
BFlat 288e93c11f Merge pull request #1170 from storytold/fix/1151-xwayland-drops
#1151: Preferences › Performance › Linux Display Server opens the window through XWayland, where file drops work
2026-10-11 17:29:43 +07:00
bflatasticandClaude Opus 5.5 a809f270eb #1151: Preferences › Performance › Linux display server opens the window through Xwayland, where file drops work
winit 0.30 delivers no file drops on native Wayland. The new Linux Display
Server preference (Automatic / X11 (XWayland), shown on Linux only, read at
startup) has winit open the window on X11 when the session is Wayland,
DISPLAY is set and the X11 libraries winit loads are installed (ldconfig
check ported from PhotoCraft); otherwise the window opens on Wayland and
the log says why. README, development notes, parity docs, ROADMAP and the
complete catalogs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:29:23 +07:00
BFlat 22ff29167e Merge pull request #1167 from storytold/fix/1103-marquee-inside-shape
#1103: a marquee selects only the objects whose paths it touches
2026-10-11 17:16:44 +07:00
bflatasticandClaude Opus 5.5 171c382618 #1103: a marquee selects only the objects whose paths it touches
A marquee drawn wholly inside a filled shape counted as touching it, so
a marquee over a background (the usual way to pick small objects off it
in Outline view) selected the background too. As in Illustrator, the
marquee now needs the path itself: an anchor inside it or a segment
crossing its edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:33 +07:00
BFlat e3e6869043 Merge pull request #1166 from storytold/fix/1152-offset-path-memory
#1152: Offset Path opens on the values it was last applied with
2026-10-11 17:16:28 +07:00
bflatasticandClaude Opus 5.5 c7522b7f0c #1152: Offset Path opens on the values it was last applied with
Object › Path › Offset Path (and `ui.menuDialog` for it) opens on the
offset, joins and miter limit of the last time OK was pressed, as in
Illustrator, instead of 10 pt and Miter every time. The values are kept
with the rest of the remembered dialog options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:16 +07:00
BFlat 4eaa5f0b56 Merge pull request #1160 from storytold/fix/1133-zigzag-dialog
#1133: Zig Zag's dialog has sliders and radio buttons, as in Illustrator
2026-10-11 16:46:26 +07:00
bflatasticandClaude Opus 5.5 f68f66a579 #1133: Zig Zag's dialog has sliders and radio buttons, as in Illustrator
Effect › Distort & Transform › Zig Zag… lays out its options the way
Illustrator's dialog does: Size and Ridges per segment each have a
slider beside their field, Relative / Absolute are radio buttons under
Size, and Points offers Smooth and Corner as radio buttons instead of a
dropdown. The label reads "Ridges per segment".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:46:14 +07:00
BFlat 0c6814e760 Merge pull request #1159 from storytold/fix/1144-curved-live-corners
#1144: Live Corners round corners between curved segments too
2026-10-11 16:46:00 +07:00
piero-83 87a9fb06cd docs: explain strict automation root arguments 2026-10-11 11:18:49 +02:00
piero-83 dea33fe5b4 test(cli): preserve confinement after rejected paths 2026-10-11 11:18:43 +02:00
piero-83 d9c48bddef test(app): reject root errors before initialization 2026-10-11 11:18:31 +02:00
piero-83 3d4caa917a fix(app): reject malformed automation roots before startup 2026-10-11 11:17:58 +02:00
bflatasticandClaude Opus 5.5 fbc1c494b2 #1144: Live Corners round corners between curved segments too
A corner is now any anchor that isn't smooth where two sides meet at an
angle, straight or curved, as in Illustrator. The cut meets each side at
its setback measured along the curve, leaves both sides along their
tangents there, and each curved side is trimmed to what the cuts at its
ends leave of it. The largest radius is still the one taking the cut
halfway along the shorter side, measured along the curve. Corners
between straight sides draw exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:14:08 +07:00
BFlat b08482d640 Merge pull request #1155 from storytold/fix/vello-flatten-guard
Render: a shape far beyond any page is left undrawn, never crashing the app
2026-10-11 16:13:40 +07:00
bflatasticandClaude Opus 5.5 255fefe71f Render: a shape far beyond any page is left undrawn, never crashing the app
A property test of the importers (import_fuzz) crashed CI with
STATUS_STACK_BUFFER_OVERRUN: a shape with coordinates in the 1e20s from a
mutated file made vello_cpu either panic in flatten_simd on a rayon
worker (rayon then aborts the process, so the engine's panic guard never
sees it) or ask for a 132 GB allocation, which aborts too. A malformed
file could end the app and lose the user's work.

draw_shape now leaves a shape undrawn when its control points, through
the view, aren't finite or reach past 1e10 pixels, or a visible stroke
is that wide (MAX_DEVICE). A document's art stays within 4 million
points, 2.6e9 pixels at the closest zoom, and vello was checked safe
with 90,000 random shapes within 1e9 pixels and 30,000 within 1e12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:13:30 +07:00
BFlat f5fab833d0 Merge pull request #1154 from storytold/fix/1139-multi-artboard-select
#1139: Shift-click selects several artboards, which move and delete together
2026-10-11 16:04:49 +07:00
bflatasticandClaude Opus 5.5 423cb18f40 #1139: Shift-click selects several artboards, which move and delete together
The Artboard tool and the Artboards panel only ever had one artboard
selected, so Shift-clicking another didn't add it. As in Illustrator:

- Artboard tool: Shift-click adds an artboard to the selection (it
  becomes active) or takes one out; a plain click on a selected one keeps
  the selection, so a drag moves them all (Alt copies them all, the
  copies becoming the selection), and Delete removes them all, one
  artboard always staying. The other selected artboards show dashed
  bounds; `selected` is a tool option.
- Artboards panel: with the Artboard tool, Shift- or Ctrl/Cmd-click on a
  row adds or removes it, every selected row is highlighted, and Delete
  Artboard removes them all.
- `artboard.move` and `artboard.delete` take `indices` besides `index`:
  several artboards in one undo step, each with its art (an object moved
  once where artboards overlap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:04:30 +07:00
BFlat 65a4847eed Merge pull request #1143 from Biromedic/fix/hidden-mask-art
Fix #1141: SVG import keeps a mask whose art is all hidden, so its object stays hidden
2026-10-11 16:02:41 +07:00
BFlat 6daba37d3b Merge pull request #1142 from Biromedic/fix/gradient-alpha-space
Fix #1140: gradient stops blend colour and opacity apart on the canvas, as the exports do
2026-10-11 16:02:26 +07:00
BFlat 6fc725d2fa Merge pull request #1132 from dotCooCoo/feat/web-clippy-dead-code
Lint: clippy -D warnings passes for the wasm32 web build
2026-10-11 16:02:09 +07:00
BFlat 34df89e8e0 Merge pull request #1130 from mateususa/fix/819-trace-noise
#819: more noise removal never gives more paths
2026-10-11 16:01:40 +07:00
BFlat b192e2cf30 Merge pull request #1128 from mateususa/fix/662-desktop-shortcut
Fix #662: the Windows installer offers a desktop shortcut
2026-10-11 16:01:17 +07:00
BFlat 93277ef87a Merge pull request #1116 from Ni-zav/fix/font-menu-highlight-rebuild-s20261011T0359Z-c8a1
fix(fonts): preserve highlighted font across menu list refreshes
2026-10-11 16:01:03 +07:00
bflatasticandClaude Opus 5.5 58c874b128 Review: progress log for SVG masks with hidden art (#1143)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:39 +07:00
Biromedic f4035c98d1 Fix #1141: SVG import keeps a mask whose art is all hidden, so its object stays hidden
Mask art that is all visibility:hidden imports as nothing, and opacity_mask
then returned no mask: the object was drawn whole. An empty mask is what the
file means (black all over); the renderer and the PDF and SVG exports already
draw empty mask art by its clip and invert options.
2026-10-11 16:00:38 +07:00
bflatasticandClaude Opus 5.5 369948f816 Review: progress log for gradient stop opacity on the canvas (#1142)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:37 +07:00
Biromedic 1726c600d2 Fix #1140: gradient stops blend colour and opacity apart on the canvas, as the exports do
The canvas built its peniko gradients premultiplied (peniko's default), while
Gradient::sample, the PDF export (shading plus SMask) and SVG viewers blend
colour and opacity separately: a transparent white stop into black drew grey
127 halfway instead of 191. Opaque gradients are unchanged.
2026-10-11 16:00:36 +07:00
bflatasticandClaude Opus 5.5 5aa1605c17 Review: progress log for the wasm32 clippy fix (#1132)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:35 +07:00
dotCooCoo d79aa607cc Lint: clippy -D warnings passes for the wasm32 web build
On main, cargo xtask wasm printed two dead-code warnings in
vectorcraft-ui-egui, and this clippy run reported the same two as
errors:

    cargo clippy --target wasm32-unknown-unknown -p vectorcraft-web -- -D warnings

ask_update_changed (dialogs/missing_links.rs) is called only from
tick_link_updates, and is_c_locale (i18n/mod.rs) is called only from
detect_system_lang. Both callers are compiled only for native targets.

Each function now has the same #[cfg(not(target_arch = "wasm32"))]
attribute as its caller. Native builds compile both functions as
before.
2026-10-11 16:00:33 +07:00
bflatasticandClaude Opus 5.5 3bd04a082e Review: progress log for Image Trace's Noise (#1130)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:32 +07:00
mateususaandClaude Opus 5.5 ae9cd2a03c #819: more noise removal never gives more paths
Remove Noise merged each small component into its most common neighbour
in one pass. When a speck was merged into a small region that was itself
merged away later, the region's flood fill skipped the speck's pixels
(already seen), so they stayed behind as a new island of the region's old
label. Higher Noise values merge more regions, so they left more such
islands: on noisy images the path count rose with Noise (70 -> 126 -> 206
paths from Noise 50 to 200 on one test image). denoise now repeats its pass
until one changes nothing; each merge joins two components, so it ends
(with a cap of 64 passes as a backstop). Labels shorter than w x h are left
alone instead of indexed past their end.

Tests: the minimal one-row case, a property test on random blocky noise
(no mergeable region under the minimum is left, and a larger minimum never
leaves more regions), and two noisy images whose path count must not rise
with Noise (both fail without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:31 +07:00
bflatasticandClaude Opus 5.5 3ce08507c1 Review: say why ICE validation flags the desktop shortcut (#1128)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:30 +07:00
mateususaandClaude Opus 5.5 1f3d469c33 Fix #662: the Windows installer offers a desktop shortcut
The MSI's wizard now has a Shortcuts page between the install folder and
Ready, with a ticked "Create a desktop shortcut" box (the Start Menu
shortcut is always installed, as the page says). The box sets the secure
INSTALLDESKTOPSHORTCUT property; the shortcut's component is installed
only with it, on the common desktop of this per-machine install, and is
removed with the app. msiexec /qn installs it too unless
INSTALLDESKTOPSHORTCUT="" is passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:29 +07:00
bflatasticandClaude Opus 5.5 eeb78520e1 Review: a headless test of the font menu's highlight across a list rebuild (#1116)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:28 +07:00
Nigel Ihza Firdausy eb189a36c3 fix(fonts): end live preview when filters remove its highlighted row 2026-10-11 16:00:27 +07:00
Nigel Ihza Firdausy 22690c3565 fix(fonts): retain the same highlighted font after list rebuilds 2026-10-11 16:00:27 +07:00
BFlat fad7248e7d Merge pull request #1150 from storytold/fix/selection-arrow-colours
Icons: the Selection arrow is black and Direct Selection's white in every theme
2026-10-11 15:30:34 +07:00
bflatasticandClaude Opus 5.5 2afd0b659c Icons: the Selection arrow is black and Direct Selection's white in every theme
Icons are drawn in the theme's colour. The Selection tool's filled arrow
therefore came out light in the dark themes and the Direct Selection
tool's hollow one dark, so they looked swapped to anyone who knows the
black Selection and white Direct Selection arrows (reported by a user).
Both arrows now paint a fill in a fixed colour (black, white;
dc-arrow-fill.svg, original) under their outline in the theme's colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:30:24 +07:00
BFlat 0089e7bb8b Merge pull request #1149 from storytold/fix/1018-edit-toolbar-drawer
#1018: Edit Toolbar opens a drawer beside the toolbar; tools drag in and out
2026-10-11 15:30:18 +07:00
bflatasticandClaude Opus 5.5 539759429c #1018: Edit Toolbar opens a drawer beside the toolbar; tools drag in and out
The ••• button at the bottom of the toolbar no longer opens All Tools as a
centred dialog. It opens the All Tools drawer beside the toolbar, next to the
button. Escape or a click outside closes it; a click in the toolbar doesn't.
Tools that are already in the toolbar show dimmed in the drawer.

While the drawer is open the toolbar can be edited, as the public
documentation describes:
- drag a tool from the drawer onto a toolbar button's top or bottom edge
  (left or right edge in two columns) to put it in its own slot there;
- drag it onto a button's middle to add it to that button's group;
- drag a toolbar button into the drawer to take its tool out;
- drag a toolbar button to another place in the toolbar to move its tool.
A line or outline shows where the tool will go, and the dragged tool follows
the pointer. While the drawer is closed the toolbar can't be edited.

The Basic and Advanced toolbars each keep their own edits in
UiState::toolbar_edits, which is saved with the preferences. It is serde
default, so older preferences still load, and a hand-edited file is cleaned
up when it loads. An edit that brings back the default toolbar clears the
saved edits. New UI commands toolbar.addTool {tool, slot?, place?},
toolbar.removeTool {tool, group?}, toolbar.reset and
window.editToolbar {open?} let MCP and the control channel make the same
edits. The drawer also has a Reset button.

The drawer moved from dialogs/all_tools.rs into the new edit_toolbar.rs. It
is tested with headless egui frame tests driven by pointer events (placement,
Escape, click outside, dimming, drag and drop in each direction, the locked
toolbar), along with command, persistence and drop-zone tests. The three new
command labels are translated in every complete catalog, and the error
messages reuse existing translated templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:30:09 +07:00
BFlat 08ab1676b0 Merge pull request #1146 from storytold/fix/812-star-radius-modifiers
Part of #812: Ctrl/Cmd and Alt shape a star while it's dragged
2026-10-11 14:59:21 +07:00
bflatasticandClaude Opus 5.5 eb510f4194 Part of #812: Ctrl/Cmd and Alt shape a star while it's dragged
A star's inner radius was always half its outer one while drawing, so
its ratio couldn't be changed. As Illustrator documents for the Star
tool: Ctrl/Cmd during the drag keeps the inner radius where it is while
the outer one follows the pointer (the new ratio stays once the key is
let go, for the rest of the drag and the next stars), and Alt keeps the
star's shoulders straight (each point's sides in line with the next
points', a ratio of cos(2π/n)/cos(π/n); five points or more).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:59:05 +07:00
BFlat cb9951918a Merge pull request #1145 from storytold/fix/788-svg-place-viewbox
#788: file.place frames an SVG by its viewBox when crop is given
2026-10-11 14:58:52 +07:00
bflatasticandClaude Opus 5.5 82b22af43c #788: file.place frames an SVG by its viewBox when crop is given
Placing an SVG fitted and centred it by its art's bounds whatever `crop`
said, so art drawn on a shared canvas lost its place and scale, and
`crop: "crop"` and `crop: "bounding"` gave the same result without a
warning. With `crop: "crop"` given, an SVG is now framed and clipped by
its viewBox (its artboard, as File › Open has it), as a PDF is by its
page: `rect` and `at` fit and centre that frame. By default, as
Illustrator places an SVG, and with "bounding", it is still its art's
bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:58:33 +07:00
BFlat 9bcac8f1e6 Merge pull request #1135 from storytold/fix/1089-paste-image-markup
#1089: an image copied in a browser pastes as the picture, also from its markup
2026-10-11 13:51:51 +07:00
bflatasticandClaude Opus 5.5 8c69173bb7 #1089: an image copied in a browser pastes as the picture, also from its markup
A browser's Copy Image puts the picture on the clipboard with text next
to it. Paste already took the picture when that text was only the
image's address (#597), but some browsers (seen on Fedora with KDE) put
the image's HTML there instead, `<img src="…" class="…" style="…">`,
which isn't an address, so the markup was pasted as type.

names_a_picture also takes markup whose only content is <img> elements
(no text outside its tags) as naming the picture, and Paste then reads
the bitmap. Markup with words in it, and an address alone, still paste
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:51:41 +07:00
BFlat c7d29e5b92 Merge pull request #1134 from storytold/fix/1129-toolbar-proxy-size
#1129: the toolbar's paint buttons fit its width
2026-10-11 13:51:27 +07:00
bflatasticandClaude Opus 5.5 b9aca50c3c #1129: the toolbar's paint buttons fit its width
The Fill/Stroke proxy stayed 36 pt and the Color, Gradient and None
buttons 7.5 pt in both toolbar layouts, so in two columns they sat small
in a wide strip and were hard to hit (in one column too). The proxy is
now 54 pt in two columns, the three buttons 11 pt in one column and
17 pt in two, and in two columns Draw Normal, Draw Behind and Draw
Inside sit side by side, each setting its mode (view.drawMode {mode}),
as Illustrator's two-column toolbar shows them; one column keeps the
button that cycles them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:26:24 +07:00
BFlat a2314cf803 Merge pull request #1104 from bobtherobot/fix/direct-selection-drag-copy
Direct Selection: drag paths selected whole by an anchor; Alt-copy at any time, snapping by that anchor
2026-10-11 12:26:09 +07:00
BFlat 6c04fb56c8 Merge pull request #1101 from p0lyg0n/fix/pdf-japanese-spacing
PDF/.ai import: glyphs spaced each their own way keep their place
2026-10-11 12:25:51 +07:00
BFlat 0f162176be Merge pull request #1091 from webdevtodayjason/fix/plugin-int-precision
Plug-ins: whole-number parameters keep their exact value and stay in range
2026-10-11 12:25:38 +07:00
BFlat 91eeb5610c Merge pull request #1082 from imachus/pr/ja-vpal
Type: Proportional Metrics in vertical type — upright glyphs on the font's proportional heights, `vpal` (#966)
2026-10-11 12:25:23 +07:00
BFlat 632cff0068 Merge pull request #1081 from NoJuanNobody/fix/893-shape-builder-seams
#893: Shape Builder merges leave no seams on grids of lines
2026-10-11 12:25:13 +07:00
bflatasticandClaude Opus 5.5 1992239325 Review: Direct Selection drags in the UI checklist; ROADMAP (#1104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:47 +07:00
bflatasticandClaude Opus 5.5 ded39b51cc ROADMAP: PDF import keeps glyphs spaced each their own way in the progress log (#1101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
bflatasticandClaude Opus 5.5 5e9b7d09a2 Review: a whole number past i64::MAX is past any maximum; ROADMAP (#1091)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 a75b465419 Selection tools: Alt-drag copies snap to the original they leave behind
The snap targets of a copy were gathered when Alt went down, from the document
as previewed, where the original had already moved with the drag, so the
original was never a target where it stays. Now both target sets (for a move,
and for a copy, which includes the original) are gathered when the drag
begins, and Alt picks between them each frame: for Direct Selection drags by
an anchor and for whole-object moves (the Selection tool's, and Direct
Selection's from a segment or fill).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Hiroshi InukaiandClaude Opus 5.5 aa9f0153a4 PDF/.ai import: glyphs spaced each their own way keep their place
Character tightening and optical kerning space each glyph differently, as
Japanese titles often are. The importer evened a line out with one
tracking value (type on a path took none), leaving glyphs up to an eighth
of an em out of place. When the file's font is installed and one value
can't place every glyph, each character now keeps its own tracking,
measured along the baseline (along the path for type on a path).

Glyphs closed up by up to half an em (the proportional widths of CJK
punctuation) also continue their line instead of starting another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 af98309c01 Direct Selection: Alt during a drag of whole paths turns the copy on and off
Pressing an anchor of paths that are all selected whole now moves them as
objects held by that anchor, so Alt pressed or released during the drag turns
the copy on and off (the original shows or not), as with the Selection tool.
The anchor keeps snapping all along, onto the original only while a copy
leaves it behind. With only some anchors selected the drag still moves just
those.

A committed move drag is now named for what it did in the end (Copy or Move),
for the Selection tool too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 5dcc64e757 Direct Selection: Alt-drag copies snap by the anchor pressed
Alt-dragging a path by an anchor moved the copy with whole-object snapping
(bounds only, from the press point), so the anchor couldn't land exactly on
another anchor. Now the pressed anchor (its own coordinates, not the pointer's)
snaps as a dragged anchor does: onto other paths' anchors and segments and
Smart Guides, and onto the original the copy leaves behind. The history says
Copy for an Alt-drag.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 abfcd0800e Direct Selection: Alt-drag by an anchor copies the whole path
Alt-pressing a segment already picked the whole path and Alt-drag copied it,
but a press on an anchor ignored Alt and only moved the selected anchors. Now
Alt on an anchor picks the whole path too, so Alt-drag duplicates it as the
Selection tool's does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
Michael GiesonandClaude Opus 5.5 fe5955e521 Direct Selection: pressing an anchor of a path selected whole keeps all its anchors
A path with all its anchors selected is stored whole (no partial entry), so
pressing one of its anchors looked unselected and replaced the selection with
that anchor alone; the drag then moved just it. Now an anchor of a path
selected whole counts as selected: the press keeps the selection and the drag
moves every anchor. Shift-press still deselects that one anchor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:24:46 +07:00
bflatasticandClaude Opus 5.5 fe9838f296 Review: progress docs for proportional vertical metrics; ROADMAP (#1082)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:45 +07:00
bflatasticandClaude Opus 5.5 9896eda208 Review: progress docs dated the day #893 landed; ROADMAP (#1081)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:45 +07:00
webdevtodayjason aecf88c1c1 Plug-ins: whole-number parameters keep their exact value and stay in range 2026-10-11 12:24:45 +07:00
imachusandClaude Opus 5.5 2c8b8b0cc1 Type: Proportional Metrics in vertical type — upright glyphs on the font's proportional heights, vpal (#966)
The second half of #966. With Proportional Metrics on, a vertical segment is
also shaped top to bottom, with and without `vpal`: the difference gives
each glyph's change of advance down the column and its x and y placement.
An upright glyph (not tate-chu-yoko) in a font with vertical metrics takes
them on top of its cell, so it moves with its shorter cell; a font without
vertical metrics keeps `upright_cell`'s cell, and with the switch off
nothing is shaped twice and the column is as before.

As in horizontal type, Line-end Punctuation Half Width takes nothing more
off the punctuation `vpal` re-spaced and trims the punctuation it leaves
full height as before. Docs: mcp.md, and Proportional Metrics leaves the
missing lists in gaps.md, type-parity.md and target-app-parity.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:45 +07:00
webdevtodayjason f79858c12c Contributors: add webdevtodayjason (Jason Brashear)
# Conflicts:
#	contributors/people.toml
2026-10-11 12:24:45 +07:00
Alejandro LondonoandClaude Opus 5.5 8957545c33 #893: merge Shape Builder faces in the planar map
After linesweeper 0.5 (#1028 on main), uniting the faces of a grid of
lines could split the merged strip again even with their corners shared
exactly: a sweep over seven or more faces moved shared vertices by about
1e-6 (within its eps) and the edges between faces no longer cancelled.

Faces cut by open paths now merge like closed shapes' faces already do
(FaceMerger::exact): in the planar map itself. build() is split into
map() (the graph and its faces) and build(); Map::merge traces the
boundary of the picked faces along the map's own edges, dropping the
edges between two picked faces and the loose ends inside them, so no
sweep runs on the result. FaceMerger finds each face again by a point
inside it, checks the area, and keeps the boolean union as a fallback.

Tests: the regression test merges through FaceMerger; a grid test
covers a ring with its hole, cells touching at a corner, a loose end,
and that the map path (not the fallback) produced the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:44 +07:00
Alejandro LondonoandClaude Opus 5.5 bc762eee99 Contributors: add my names (NoJuanNobody)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:44 +07:00
Alejandro LondonoandClaude Opus 5.5 cfa36fc55f #893: Shape Builder merges leave no seams on grids of lines
The planar map merged sweep endpoints within eps into one vertex id but
kept each segment's own copy of the coordinates, so faces meeting at a
vertex could disagree on that corner by ~1e-13. Uniting them then failed
to cancel their shared edge, and a merged strip came back as several
touching subpaths whose stroke drew the inner edges as leftover lines.

Pin every edge's ends to its vertex's canonical position, so faces share
their corners bit for bit. Regression test from the reporter's grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:44 +07:00
BFlat a3b6c3d969 Merge pull request #1099 from Emlembow/fix/867-macos-file-sheets
Fix #867: macOS file dialogs are sheets, and no dialog runs inside the event handler
2026-10-11 12:14:39 +07:00
EmlembowandClaude Opus 5.5 7ff0269a1d Fix #867: macOS file dialogs are sheets, and no dialog runs inside the event handler
The crash: rfd's blocking dialog ran -[NSSavePanel runModal] inside
winit's event handler; when a resize of the panel ends, AppKit runs the
closures winit queued meanwhile, and winit's re-entrancy panic fires
inside an Objective-C block, where it can't unwind, so the app aborts.
As PhotoCraft (#673) and PdfCraft (#145) ship it:

- macOS: picks::ask shows an rfd::AsyncFileDialog as a sheet on the
  window (beginSheetModalForWindow returns at once); a waiter thread
  sends the paths and wakes the window. A pick asked outside an entry is
  refused and logged instead of running a modal. Linux keeps its thread
  (#592) and now also wakes the window; Windows is unchanged.
- Nine paths that asked outside an entry now run as entries: Load
  Profile, Swatches and Graphic Styles Other Library, the shortcut
  editor's Import and Export, and the presets dialogs' Export.
- While a dialog is open: the macOS menu bar is inert except Hide, Hide
  Others and Minimize; Quit, Close, Close All and the close button are
  refused; Finder/Dock opens and the IME discard wait; another dialog
  is refused with a status message. The answer re-activates the
  document that asked (by uid) and Save Changes finds its document by
  uid; a frame guard clears a stale picks entry after a recovered panic.
- ui.inspect reports fileDialog.

Squashed onto current main by the maintainer (with main's automation
sandbox wrappers and font-name menus kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:14:17 +07:00
BFlat cd1fac5393 Merge pull request #1127 from storytold/fix/1111-paragraph-align-shortcuts
#1111: Ctrl+Shift+L, C, R, J and F align paragraphs
2026-10-11 12:11:21 +07:00
bflatasticandClaude Opus 5.5 525b79a51a #1111: Ctrl+Shift+L, C, R, J and F align paragraphs
The paragraph alignment shortcuts were missing. Cmd/Ctrl+Shift+L, C and
R align the selected type's paragraphs left, centred and right, J
justifies them with the last line aligned left and F justifies all
lines; while the Type tool edits text they act on the paragraphs its
selection touches, through the Paragraph panel's own path. They are UI
commands (type.alignLeft, …), listed in Keyboard Shortcuts, and enabled
while type is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:58:51 +07:00
BFlat 6ca54579cd Merge pull request #1122 from storytold/fix/1095-artboard-label-rotated
#1095: an artboard's name stays at its corner in a rotated view
2026-10-11 11:43:30 +07:00
bflatasticandClaude Opus 5.5 558b8b621c #1095: an artboard's name stays at its corner in a rotated view
The name was placed at the top-left corner of the artboard's bounding box
on screen, which a rotated view (Rotate View) turns away from the
artboard's own corner, so the name floated off beside it. It now sits
above the artboard's top-left corner as the view maps it, no wider than
the artboard's on-screen width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:11 +07:00
BFlat 012fe675a2 Merge pull request #1110 from dotCooCoo/feat/ase-save-count
Swatches: swatch.library.save counts only the swatches the file holds
2026-10-11 11:35:23 +07:00
BFlat 12ad39e7cc Merge pull request #1109 from dotCooCoo/feat/lock-hide-above-deselect
Object: Lock and Hide › All Artwork Above deselect the objects they lock or hide
2026-10-11 11:35:13 +07:00
BFlat f657110510 Merge pull request #1108 from dotCooCoo/feat/align-moves-pattern-tiles
Part of #394: Align and Distribute move pattern tiles when Transform Pattern Tiles is on
2026-10-11 11:35:02 +07:00
BFlat b10d6c63ee Merge pull request #1107 from dotCooCoo/feat/setprops-hidden-leave-selection
MCP: object.setProps removes the objects it hides or locks from the selection
2026-10-11 11:34:46 +07:00
BFlat 3eeb22179d Merge pull request #1106 from dotCooCoo/feat/align-refuses-unknown-values
Align, Reflect and Shear: object.align needs a direction; unknown choices are errors
2026-10-11 11:34:30 +07:00
BFlat b0ba64a940 Merge pull request #1105 from dotCooCoo/feat/align-active-artboard
Align: Align to Artboard uses the active artboard
2026-10-11 11:34:20 +07:00
BFlat 050e355e6c Merge pull request #1088 from dotCooCoo/feat/key-object-needs-two
Align: a key object needs two or more selected objects
2026-10-11 11:34:04 +07:00
BFlat b1e4c4e522 Merge pull request #1083 from dotCooCoo/feat/drops-open-libraries
Drops: libraries, presets and plug-ins open on a drop, as File › Open opens them
2026-10-11 11:33:50 +07:00
bflatasticandClaude Opus 5.5 c7b46dbb3e Review: palette writing keeps the swatches PaletteFormat::holds names; ROADMAP (#1110)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:07 +07:00
bflatasticandClaude Opus 5.5 4d52b043cf Review: Lock and Hide › All Artwork Above use Selection::deselect_uneditable; ROADMAP (#1109)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:07 +07:00
dotCooCoo 2b93e8a930 Swatches: swatch.library.save counts only the swatches the file holds
swatch.library.save replied with the number of solid colors and
gradients it was asked to save, including the gradients the format
leaves out. For a new document saved as .ase, count was 50 and the file
held 46 colors. The .gpl and .ase writers keep solid colors only, and
the CSS writer leaves freeform gradients out.

count is now the number of swatches written. The new
PaletteFormat::holds returns whether a file of a format keeps a swatch
with a given paint, and swatch.library.save counts the swatches for
which it returns true. The reply has the same fields as before.

Tests: after a freeform gradient is added to a new document's swatches,
the test checks that count has these values: 51 for .vcswatches, 46 for
.gpl and .ase, and 50 for CSS. Each file holds that many swatches.

Docs: docs/mcp.md.
2026-10-11 11:33:07 +07:00
bflatasticandClaude Opus 5.5 cc6fd8a5dc Review: Align and Distribute share one apply_moves; ROADMAP (#1108)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:06 +07:00
dotCooCoo cd34e4097a Object: Lock and Hide › All Artwork Above deselect the objects they lock or hide
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above
lock or hide the objects that are above the selection in its layer and
overlap it. Such an object can be a group or a sublayer that holds
another selected object. The commands left that object selected, the
key object included. Align then moved the locked object, or aligned
the other selected objects to the hidden key object. object.lock and
object.hide clear the selection, and layer.setProps, which the Layers
panel's eye and lock columns run, deselects what it hides or locks.

Both commands now deselect every selected object that is locked or
hidden after the command, as layer.setProps does, and clear the key
object when it is one of them. Undo selects the objects again and
restores the key object.

Docs: the object.lock.above and object.hide.above params text,
docs/target-app-parity.md.
2026-10-11 11:33:06 +07:00
bflatasticandClaude Opus 5.5 d10962ac45 Review: one Selection::deselect_uneditable for the hide and lock commands; ROADMAP (#1107)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:05 +07:00
dotCooCoo a31cdab606 Part of #394: Align and Distribute move pattern tiles when Transform Pattern Tiles is on
object.align, object.distribute and object.distributeSpacing moved
objects without their pattern tiles, whatever General › Transform
Pattern Tiles was set to. They now follow the preference as Move,
Nudge and the Selection tool do: with it on, pattern fills and strokes
move with the art. Each also takes a `patterns` param for one call, as
the transforms do. When the art uses a pattern, the journal records the
choice, and a recorded action plays back with it.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
2026-10-11 11:33:05 +07:00
dotCooCoo 975dea056c MCP: object.setProps removes the objects it hides or locks from the selection
object.setProps with visible: false or locked: true left the objects
it hid or locked selected, the key object included, and kept selected
objects inside a group or layer it hid or locked. Align then moved a
locked object, and aligned the other objects to a hidden key object.
object.lock and object.hide clear the selection, and layer.setProps,
which the Layers panel's eye and lock columns run, deselects what it
hides or locks.

After a call with visible: false or locked: true, object.setProps now
deselects every selected object that is then hidden or locked, as
layer.setProps does: the selected objects it hides or locks, selected
objects inside them, and selected objects that were hidden or locked
before the call. It clears the key object when the key is one of
them. Undo selects them again and restores the key object. A call
with visible: false or locked: true that changes nothing records no
undo step and still deselects them, as object.lock and object.hide
do. A call that sets neither, such as an opacity change from the
Control bar or the Transparency panel, leaves the selection as it is.

Docs: the object.setProps params text, docs/mcp.md, docs/target-app-parity.md.
2026-10-11 11:33:05 +07:00
bflatasticandClaude Opus 5.5 0a8d334510 Review: Align and Shear choice errors reuse the catalog's one-of message; ROADMAP (#1106)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:04 +07:00
dotCooCoo a2abe63e8d Align, Reflect and Shear: object.align needs a direction; unknown choices are errors
object.align with neither horizontal nor vertical returned success
and moved nothing. The command palette, and a key bound to it in
Keyboard Shortcuts, run it with no params. It now returns
"give horizontal or vertical", as object.distribute already did.
An action recorded while Align ran from the palette or a bound key
contains an object.align step with no params. Playing that action now
fails at that step, and command.batch rolls back the whole action.

Values outside a parameter's choices were read as another choice or
ignored. Align ignored an unknown horizontal or vertical and aligned
to the selection for an unknown to value. Distribute read an unknown
direction as center. Distribute Spacing and Shear read any axis other
than "vertical" as horizontal, and Reflect read any axis other than
"horizontal" or a number as vertical. A bounds or to value that was
not a string fell back to the default, and a spacing that was not a
number gave automatic spacing. The commands now return an error that
names such a value, before they move anything or record an undo
step. When a to, bounds, axis or spacing value is missing or null,
the command uses the parameter's default: Distribute Spacing without
an axis spaces the objects horizontally.

The run_command description and docs/mcp.md showed object.align with
an align param it does not have; they now use horizontal. The new
status-bar messages have rows in the de, es, fr, it, ja, ru and uk
catalogs.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-11 11:33:03 +07:00
bflatasticandClaude Opus 5.5 0ff9e35374 Review: lone-object Align wording after #1088; ROADMAP (#1105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:02 +07:00
bflatasticandClaude Opus 5.5 e02c2dc899 ROADMAP: a key object needs two or more selected objects in the progress log (#1088)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:01 +07:00
dotCooCoo 8e4158b364 Align: Align to Artboard uses the active artboard
Align to Artboard aligned the selection to the artboard under its
center, or to the first artboard when no artboard was under the
center. A press with a selection tool inside an artboard makes that
artboard the active one (#693). With the Selection tool, a
Shift-click on an empty spot inside an artboard also makes it active
and keeps the selection. Align to Artboard did not use the active
artboard.

object.align takes artboard, a 0-based artboard index, and uses it
when its to param is "artboard". The app passes its active artboard,
the one the Artboards panel highlights, to each object.align it runs
that names none, as it does for Paste in Place, Paste in Front, Paste
in Back (#693) and Select › All on Active Artboard (#1006). MCP calls
in remote mode also get it. Without artboard (headless MCP and the
CLI), or with an index the document doesn't have, object.align aligns
to the artboard under the center of the selection, or to the first
artboard when no artboard is under the center.

command.batch takes artboard too, and the app passes it the active
artboard. The batch passes it on to each step of these five commands
that names none, when the step runs. When the document a step runs in
has no artboard at that index (for example, after an earlier step
deleted the active artboard when it was the last one), the step gets
that document's last artboard. A step in a document that an earlier
step opened, created or switched to gets the same index, not that
document's own active artboard.

With Align to Selection, one selected object aligned from the Align
panel, the Control bar or the Properties panel is aligned to the
active artboard too, unless it is the key object.

Recorded actions leave out the artboard that the app passes to these
commands and to the batch. The Actions panel plays an action as a
batch. Its Align to Artboard, All on Active Artboard and in-place
paste steps that name no artboard use the artboard that is active
when it plays, and the default Center on Artboard action now centers
on the active artboard. In-place paste steps recorded since #693 and
before this change still paste onto the artboard that was active when
they were recorded.

Tests cover object.align with and without artboard on three artboards;
a Shift-click that makes an artboard active, after which Align to
Artboard aligns to that artboard but keeps an artboard the call names;
a batch that passes its artboard to each step when the step runs,
after a step deletes the last artboard and in a new document; recorded
actions that leave the artboard out; and recorded Align to Artboard
and Paste in Place steps and the default Center on Artboard action,
which play on the artboard that is active when they play.

Docs: docs/mcp.md (Align to Artboard), docs/target-app-parity.md.
2026-10-11 11:33:01 +07:00
bflatasticandClaude Opus 5.5 008644ea2c ROADMAP: dropped libraries, presets and plug-ins open in the progress log (#1083)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:32:59 +07:00
dotCooCoo a8eb9024da Align: a key object needs two or more selected objects
Selecting two objects, clicking one to make it the key object and then
Shift-clicking the other left the remaining object as the key. The
canvas still drew that object with the thick key-object outline. The
Align panel aligned the object to itself, so the object did not move.

The key is now cleared after any change that leaves no other selected
object outside it, such as a Shift-click, or hiding or deleting the
other object's row in the Layers panel. On a single selected object,
or on a group selected with only its own members, select.key sets no
key.

With one object selected and Align to Key Object chosen, the Align
panel, the Control bar and the Properties panel ran object.align with
to: "key", which fails with "no key object" when the selection has no
key. All three now align a single selected object to the artboard
whatever Align To is set to.

Docs: docs/target-app-parity.md.
2026-10-11 11:32:59 +07:00
dotCooCoo 05bc89827b Drops: libraries, presets and plug-ins open on a drop, as File › Open opens them
With a document open, the desktop app ran File › Place on a swatch or
graphic style library, a Libraries panel file, a presets file or a
plug-in dropped on it. A desktop drop has no position, and without one
the app opened only documents and ran File › Place on every other file.
On the web, the app also ran File › Place on these files when they were
dropped on the canvas. File › Place can't read them, and the drop
failed with "Couldn't place X: ... not a format VectorCraft reads".

VectorcraftApp::drop_target now returns Open for these files, with or
without a drop position, and they open as File › Open opens them:
plug-ins are installed, presets are imported, swatch and graphic style
libraries open in the library panel, and a Libraries panel file is
added to the Libraries panel as a new library. A dropped library,
presets file or plug-in that has a path is added to Open Recent Files
when it opens, as File › Open adds it. io::opens_as returns the kind of
each of these files from its extension, and drop_target and
io::open_bytes both call it. The rules from #472 still apply to
documents, pictures and text. On the web the browser gives no file name
before the drop, so the app still outlines the canvas while such a file
is dragged over it.

Tests: drop_target returns Open for each of the eleven extensions, with
no position and with a position on the canvas. A .gpl dropped through
the desktop drop path loads in the library panel and is added to Open
Recent Files, and a .vclibrary dropped the same way becomes a new
library in the Libraries panel.

Docs: docs/control-protocol.md (file.place), docs/development.md
(Drag-and-drop), docs/target-app-parity.md (Evidence, UI).
2026-10-11 11:32:58 +07:00
BFlat e3978d158a Merge pull request #1121 from storytold/fix/1085-dmg-strict
#1085: the DMG's app passes codesign --strict
2026-10-11 11:25:47 +07:00
bflatasticandClaude Opus 5.5 83ad194819 #1085: the DMG's app passes codesign --strict
`hdiutil makehybrid -hfs` gave every file in the image a
com.apple.FinderInfo, which `codesign --verify --strict` rejects as
"Finder information, or similar detritus" on the app in the DMG and on
copies installed from it, so compliance and MDM checks reported the
notarized app as damaged. As PdfCraft did (storytold/pdfcraft#823,
shipped in PdfCraft 0.6.0), the DMG is now made with
`hdiutil create -srcfolder -fs HFS+ -format UDZO`, retried when a CI
runner's device is busy, and the app mounted from the finished DMG must
pass `codesign --verify --strict --deep` before the release goes on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:24:36 +07:00
BFlat 61c333b8ed Merge pull request #1120 from storytold/fix/1102-eps-layers-page
#1102: an EPS keeps its page and stacked text strokes through its layers
2026-10-11 11:21:41 +07:00
bflatasticandClaude Opus 5.5 0cfdefa853 #1102: an EPS keeps its page and stacked text strokes through its layers
Since #1025 reads global colours, more Illustrator EPS files open through
their layers, and for one reported file that gave a worse result than its
page did in 0.8.0:
- the page became the editing data's artboard (344 pt tall) instead of the
  file's own bounding box (358 pt), cutting off art reaching above it. An
  EPS whose page is its art's box now keeps that page on the layers path
  too, as it does on the page path.
- prune_unseen took out text objects the page does draw: a red stroke
  stacked over a dark red one differs from it by less than the lightness
  margin, so the page looked no different without it. Pruning now weighs
  each text object by colour (any of red, green, blue more than 64 apart);
  the page check itself still compares by lightness, so colours written
  as their CMYK equivalents still match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:03:11 +07:00
BFlat 6c3f9f60aa Merge pull request #1113 from storytold/fix/width-point-index
Width points: set and copy refuse a malformed index
2026-10-11 10:50:05 +07:00
bflatasticandClaude Opus 5.5 6fd342563d Width points: set and copy refuse a malformed index
stroke.widthPoint.set and .copy read `index` with `as_u64() as usize`:
a malformed index ("bad", -1, 1.5) was taken as no index, so set added a
new point instead of failing, and a huge one wrapped round on wasm32.
They now read it through point_indices, as remove does since #1079:
anything but an index of an existing point is "no such width point".
A null index is still no index (set adds a point).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:49:42 +07:00
BFlat 8a31fda88f Merge pull request #1112 from storytold/fix/checkbox-outlines
#885: every checkbox keeps a visible box at rest
2026-10-11 10:49:35 +07:00
bflatasticandClaude Opus 5.5 c4e139af19 #885: every checkbox keeps a visible box at rest
The theme leaves egui's own checkbox without an outline while inactive,
so an unchecked box was invisible and a checked one a bare tick. #1080
moved Preferences to widgets::check; the remaining ui.checkbox uses
follow: the Properties panel's Snap to Point, Snap to Grid and Scale
Strokes & Effects, Revolve's Keep visible surfaces and Shading, Save
options, Export for Screens, the Variables dialog and plug-in forms.

widgets::check_name shows a document's name (an artboard's, an asset's)
as it is, never translated; the check and radio rows translate their
labels in the public functions, so choice_row draws text as given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:27:46 +07:00
skylar taylor-barrick 389f75fa7b Merge pull request #1090 from skylartaylor/codex/ui-suite-integration-20261010
Adopt shared docking with panel menus and saved return locations
2026-10-10 20:17:53 -06:00
skylar taylor-barrick 34eb19a5ab UI.6: qualify shared UI integration on current main 2026-10-10 19:39:47 -06:00
skylar taylor-barrick 0c57b1d5d2 UI.6: reconcile shared docking with native panel sets 2026-10-10 19:28:31 -06:00
BFlat 5d847ffc88 Merge pull request #1080 from Ni-zav/fix/885-checkbox-visibility-rebased-20261011
fix(ui): show Preferences checkbox outlines in every theme
2026-10-11 07:44:34 +07:00
BFlat 2a52b2901f Merge pull request #1079 from Ni-zav/fix/widthpoint-remove-indices-rebased-20261011
fix(width): validate every explicit width-point removal index
2026-10-11 07:44:20 +07:00
BFlat 7f4f7bd8d2 Merge pull request #1078 from Ni-zav/fix/shaper-target-validation-rebased-20261011
fix(shaper): reject invalid explicit scribble targets
2026-10-11 07:44:05 +07:00
BFlat 2729483851 Merge pull request #1077 from Ni-zav/fix/layer-options-target-validation-rebased-20261011
fix(layers): validate explicit Layer Options targets
2026-10-11 07:43:51 +07:00
bflatasticandClaude Opus 5.5 5226d5a728 Review: Preferences checkboxes pass their label untranslated to widgets::check; ROADMAP (#1080)
widgets::check translates the label itself, so tl!(label) translated it twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:15 +07:00
bflatasticandClaude Opus 5.5 dec085e1e5 Review: width-point removal reuses point_indices; ROADMAP (#1079)
point_indices moves from freeform.rs to the cmd param helpers so
stroke.widthPoint.remove parses `index` and `indices` with it, and a
malformed entry reports the translated "no such width point".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:14 +07:00
bflatasticandClaude Opus 5.5 eaf96c60ae Review: Shaper reads its explicit ids in one line; ROADMAP (#1078)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:14 +07:00
Nigel Ihza Firdausy 85bbc35176 fix(ui): show Preferences checkbox outlines in every theme
Reapply on current upstream main.
2026-10-11 07:43:14 +07:00
Nigel Ihza Firdausy 3ce51a3487 fix(width): validate explicit width-point removal indices
Reapply on current upstream main.
2026-10-11 07:43:14 +07:00
bflatasticandClaude Opus 5.5 a031ff6c7b Review: Layer Options reuses the translated id messages; ROADMAP (#1077)
The new id errors reuse messages the catalogs already translate
(`ids` must be an array of object ids, object ids are non-negative
integers, no such layer or object), so complete_languages_translate_every_message
keeps passing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:13 +07:00
Nigel Ihza Firdausy 0d748500d6 fix(shaper): reject invalid explicit scribble targets
Reapply on current upstream main.
2026-10-11 07:43:13 +07:00
Nigel Ihza Firdausy d47857a20f fix(layers): validate explicit Layer Options targets
Reapply on current upstream main.
2026-10-11 07:43:13 +07:00
BFlat c9236ed3f7 Merge pull request #1084 from storytold/fix/1068-ai-artboard-layout
#1068: a .ai read as its PDF part keeps its artboard layout
2026-10-11 07:35:59 +07:00
bflatasticandClaude Opus 5.5 2480a505cb #1068: a .ai read as its PDF part keeps its artboard layout
When a .ai's layers can't be read from its editing data yet, it opens as
its PDF part, which lists the artboards as pages; those were laid out in
a row, so a grid of artboards (or any other arrangement) was lost. The
editing data's artboards are now read even then (ai::artboards, as far
as the reader gets) and, when there are as many as pages and of the same
sizes, each page goes where its artboard is, with the art on it (the art
of a page: the objects mostly on it, else nearest it). Otherwise the
pages stay in a row as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:29:16 +07:00
BFlat 231a97dd51 Merge pull request #1074 from dotCooCoo/feat/align-compound-key-stays
Align: a compound path or selected group that contains the key object stays in place
2026-10-11 07:18:50 +07:00
dotCooCoo 1819921e03 Align: a compound path or selected group that contains the key object stays in place
Align and Distribute Spacing move a selected compound path or group as
one object, and they found the key object only when its own id was
among the objects they move. When the key was inside a compound path
or a selected group, aligning to the key moved that compound path or
group like any other object, and the key moved with it. In that case,
Distribute Spacing with a spacing held the first object still instead
of the key.

The key object can end up inside a selected group or a compound path.
Dragging its row into a group in the Layers panel (layer.move) keeps
the selection and the key. A Selection-tool Shift-click on that group,
when it is unselected and directly in a layer, then adds it to the
selection (select.toggle) and keeps the key. Other ways of adding the
group keep the key as well: a Shift-click in the Layers panel's
selection column or on the group's target circle, a Group Selection
click that hits the key itself, select.add, and select.anchors or
select.anchorsMany in add or toggle mode. In isolation mode, a
Selection-tool click on one of several selected objects in the
isolated group makes that object the key. In an isolated compound
path, the same click on the outline of a selected member makes that
member the key.

Align to the key object and Distribute Spacing with a spacing now keep
in place the selected object that is the key or contains it. Both
commands look for that object among the key and its ancestors, so a
group around the key's group also stays in place. Aligning to the key
still lines the others up with the key's own bounds.

Docs: docs/target-app-parity.md.
2026-10-11 07:18:28 +07:00
BFlat 06226bcb73 Merge pull request #1062 from dotCooCoo/feat/eps-arc-angles
EPS: arc and arcn with huge angles no longer hang the import
2026-10-11 07:15:53 +07:00
BFlat 20eaecd624 Merge pull request #1059 from dotCooCoo/feat/command-sweep-stale-exclusions
Tests: the command sweeps no longer skip junk scale, spacing and artboard values
2026-10-11 07:15:38 +07:00
BFlat 653945c8f8 Merge pull request #1058 from Ni-zav/fix/noissue-saved-view-geometry-s20261010T2146Z-4e82
fix(views): validate saved-view coordinates and rotation
2026-10-11 07:15:22 +07:00
BFlat bfde4dd918 Merge pull request #1057 from Ni-zav/fix/noissue-corner-dialog-explicit-inputs-s20261010T2146Z-4e82
fix(corners): reject malformed explicit dialog targets
2026-10-11 07:15:12 +07:00
BFlat a71348821a Merge pull request #1056 from Ni-zav/fix/noissue-freeform-line-indices-s20261010T2146Z-4e82
fix(gradient): reject malformed freeform line index lists
2026-10-11 07:15:03 +07:00
BFlat 4f60296e77 Merge pull request #1055 from Ni-zav/fix/noissue-textwrap-explicit-targets-s20261010T2146Z-4e82
fix(textwrap): reject malformed explicit object targets atomically
2026-10-11 07:14:46 +07:00
BFlat 98ccc7980b Merge pull request #1054 from Ni-zav/fix/1018-all-tools-compact-s20261010T2146Z-4e82
fix(ui): keep All Tools drawer compact on wide screens
2026-10-11 07:14:31 +07:00
BFlat 1639666a34 Merge pull request #1052 from MarekSpodBiedry/issue-1050
Gradient mesh: a fill colour recolours the point last clicked (#1050)
2026-10-11 07:14:14 +07:00
BFlat 47af13c3d4 Merge pull request #1051 from bobtherobot/fix/build-scripts-unchanged-output
Build: generated sources are rewritten only when they change
2026-10-11 07:14:00 +07:00
bflatasticandClaude Opus 5.5 1da3c0c052 ROADMAP: EPS arcs with huge angles in the progress log (#1062)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:38 +07:00
bflatasticandClaude Opus 5.5 b19731b307 ROADMAP: the command sweeps in the progress log (#1059)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:38 +07:00
dotCooCoo 2d95ac1694 EPS: arc and arcn with huge angles no longer hang the import
arc raised its second angle by 2π in a loop until it was no longer
below the first, and arcn lowered it the same way. For an angle of
1e18 degrees that loop takes about 3e15 steps: vectorcraft-cli info
was still reading an EPS file with "newpath 100 100 50 1e18 0 arc
stroke" when "timeout 20" stopped it. For 1e308 the loop never ends:
from 2^56 radians (about 4.1e18 degrees) up, adding 2π leaves a double
unchanged. The import fuzz test eps_hostile_programs_never_panic builds
programs from tokens that include arc, arcn, 1e308 and 1e15.

arc now raises the second angle by multiples of 360 until it is at
least the first, and arcn lowers it until it is at most the first, as
the PostScript Language Reference defines them. The code works out both
sweeps from the remainders of the two angles after whole turns, and it
places the first endpoint at the remainder of the first angle. Ordinary
angles give the same arcs as before, apart from rounding in the last
digits. One case changes: when raising (arc) or lowering (arcn) the
second angle by whole turns makes it equal to the first, as in
60 -300 arc or -60 300 arcn, rounding sometimes made the old loop add
another turn and draw a full circle. Those arcs are now empty, as the
PostScript Language Reference defines them.

Tests: 1e18 0 arc, 1e308 0 arc, 0 1e18 arcn and 0 1e308 arcn draw the
arc between the angles' remainders and leave the current point at the
second angle. The test reads each program on a thread of its own and
fails when no result arrives within 20 seconds.
2026-10-11 07:13:38 +07:00
bflatasticandClaude Opus 5.5 68241453dd Review: New View reads its centre with finite_numbers; ROADMAP (#1058)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:37 +07:00
bflatasticandClaude Opus 5.5 39099dcc57 Review: Corners reuses its messages; i18n; ROADMAP (#1057)
A malformed id gets the existing object-id message, and a listed anchor
that is not a corner the existing "give corners of the path" one, so
only the malformed-list message is new (translated in every catalog
that covers messages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:37 +07:00
dotCooCoo 9ea392e6ee Tests: the command sweeps no longer skip junk scale, spacing and artboard values
The sweeps `every_command_with_junk_params` and
`fuzzed_calls_keep_documents_serializable` skipped every case that set
`object.scale`'s `sx`, `object.transformEach`'s `scaleH` or `scaleV`,
`object.distributeSpacing`'s `spacing`, or `artboard.setProps`'s `x`,
`y`, `width` or `height`. The comment above `KNOWN_BUGS`, the list of
those (command, param) pairs, referred to `#[ignore]`d repro tests, but
`bug_huge_transform_makes_document_unloadable` and
`bug_artboard_huge_props_break_svg` have no `#[ignore]` and run with the
other tests. Since 42df20ca, the commit that added the list,
`Session::edit` returns an error when an edit leaves an artboard or a
selected object with coordinates that are not finite or exceed
`MAX_COORD`, and `object.transformEach` clamps `scaleH` and `scaleV`.

The list and its two checks are removed, so both sweeps run those
cases.
2026-10-11 07:13:37 +07:00
Nigel Ihza Firdausy fffec4a714 fix(views): validate saved-view coordinates and rotation 2026-10-11 07:13:37 +07:00
bflatasticandClaude Opus 5.5 adfe6e3820 Review: one point-index parser for freeform lines; i18n; ROADMAP (#1056)
parse_freeform and paint.freeform.addLine share point_indices, which also
keeps addLine from wrapping a huge index round on 32-bit targets (wasm).
The new messages are in the catalogs that translate every message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:36 +07:00
Nigel Ihza Firdausy 935a588aa2 fix(corners): reject malformed explicit dialog targets 2026-10-11 07:13:36 +07:00
bflatasticandClaude Opus 5.5 212fb9994a Review: one checked ids-or-selection helper; ROADMAP (#1055)
Text Wrap and the variable bindings share checked_ids_or_selection in
cmd/mod.rs instead of each keeping its own copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:35 +07:00
Nigel Ihza Firdausy f5111045a9 fix(gradient): reject malformed freeform line index lists 2026-10-11 07:13:35 +07:00
bflatasticandClaude Opus 5.5 c3e0ca3dfa Review: All Tools width from its cells; test order; ROADMAP (#1054)
The drawer width is worked out from the tool cell, and the new test no
longer sits between the existing test and its doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:34 +07:00
bflatasticandClaude Opus 5.5 50dbe4c10e ROADMAP: recolouring mesh points in the progress log (#1052)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:34 +07:00
Nigel Ihza Firdausy ec41d72e0d fix(textwrap): reject malformed explicit object targets atomically 2026-10-11 07:13:34 +07:00
Nigel Ihza Firdausy 63026402a1 fix(ui): keep All Tools drawer compact on wide screens (#1018) 2026-10-11 07:13:34 +07:00
bflatasticandClaude Opus 5.5 a785b53e7b ROADMAP: generated sources in the progress log (#1051)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:13:33 +07:00
MarekSpodBiedry 1217d58ca1 Gradient mesh: a fill colour recolours the point last clicked (#1050) 2026-10-11 07:13:33 +07:00
Michael GiesonandClaude Opus 5.5 c912ef59d1 Build: generated sources are rewritten only when they change
crates/text/build.rs (craft_fonts.rs) and crates/ui-egui/build.rs (credits.rs) wrote their
output on every run. Where cargo's recorded times lose their sub-second part (an ecryptfs home
directory, as Mint and Ubuntu offer at install), a file written just before its crate compiles
reads as newer than that compile, so the next build compiled the crate and everything above it
a second time: a full app rebuild for nothing after any build-script rerun. Unchanged output is
now left alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 07:13:32 +07:00
BFlat af505b197a Merge pull request #1076 from storytold/fix/1032-lab-spots
#1032: spot colours defined in Lab keep their Lab values
2026-10-11 07:06:46 +07:00
bflatasticandClaude Opus 5.5 f043a1a392 #1032: spot colours defined in Lab keep their Lab values
A .ai or PDF whose spot colours are defined in Lab opened them wrong:
- the PDF part gives such an ink an ICC Lab profile as its alternate
  space ([/ICCBased profile], 3 components), which was read as RGB, so
  the Lab values were clamped into wildly wrong colours (PANTONE 249 C
  came in yellow). A profile whose Alternate is [/Lab ...], or whose
  header names the Lab data colour space, is now read as Lab;
- the editing data writes them as `c m y k L a b (name) tint 2 Xx`,
  type 2 being Lab, and only the CMYK equivalent was read. Type 2 now
  gives the Lab colour, in the swatches and on the art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:06:05 +07:00
BFlat d01d7b2ef0 Merge pull request #1060 from dotCooCoo/feat/align-no-op-no-undo
Object: Align, Move, Arrange and other commands that change nothing record no undo step
2026-10-11 07:02:47 +07:00
dotCooCoo dc6e8a4253 Object: Align, Move, Arrange and other commands that change nothing record no undo step
These Object commands edited the document even when nothing changed, so
the call recorded an undo step, emptied Redo and marked the document
modified:

- object.align, object.distributeSpacing and object.distribute (three
  or more objects) when no object moves. With one object selected,
  Object › Align aligns it to its own bounds, so it recorded a step
  every time. Calls on objects that were already aligned or evenly
  spaced also recorded a step, including objects that an earlier Align
  or Distribute left a rounding error away from the position the next
  call computes.
- Move, Rotate, Scale, Shear, Transform and Nudge by an identity
  transform without a copy, Transform Again after one, Set Bounds with
  the current position and size, and a Selection tool drag that ends
  where it started.
- Nudge by 0 with anchors direct-selected or with only ruler guides
  selected, and Transform with nothing selected or with ids that name
  no object, with or without a copy.
- Bring to Front and Bring Forward on objects already in front, Send to
  Back and Send Backward on objects already at the back, and Send to
  Current Layer on objects already on top of the current layer.
- Ungroup, Release Compound Path and Release Clipping Mask with none of
  their objects selected, and Expand Shape with no live shape selected.
- Lock with every selected object already locked, Hide with every one
  already hidden, Unlock All with nothing locked and Show All with
  nothing hidden.
- object.setProps with the values the objects already have, or with no
  object.
- object.setLiveShape with the values the shape already has: a
  polygon's sides, angle, radius or equal sides, or a side length that
  gives exactly its current radius; an ellipse's pie, including Invert
  Pie on a whole ellipse; and a corner radius or kind that the corners
  already have.

These commands now return without editing the document, and Align and
Distribute count a move of 1e-9 pt or less on both axes as none. Such a
call records no undo step, keeps the step to redo and leaves the
document unmodified. In these cases Ungroup no longer resets the
selection, and Release Compound Path, Unlock All and Show All no longer
empty it. A Transform with a copy of no object keeps the selection.
Lock and Hide still deselect the objects. A Nudge by 0 no longer
expands a live shape whose anchors are direct-selected. The replies
have the same form as before. An identity transform still sets what
Transform Again repeats. The journal still records each call, so the
Actions panel records it as before.

When every value given to Set Bounds is the current one, it now applies
the identity transform instead of the transform computed from those
values, which can differ from the identity by a rounding error on a
rotated object or with Use Preview Bounds.

When a drag comes back to its start, the identity preview no longer
edits the document. Session::preview now advances the revision when it
undoes the previous preview, so views redraw that drag.

The undo and redo row of docs/mcp.md now notes that many calls that
change nothing record no undo step, so undo after one undoes the step
before it.
2026-10-11 07:02:28 +07:00
BFlat 327737b21a Merge pull request #1067 from imachus/pr/ja-proportional-metrics
Type: Proportional Metrics — full-width glyphs on the font's proportional widths, `palt` (#966)
2026-10-11 07:01:17 +07:00
BFlat a7a4d952ea Merge pull request #1045 from lollinoo/codex/1034-text-scale
Fix font size and horizontal scale after text/group scaling
2026-10-11 07:01:05 +07:00
imachusandClaude Opus 5.5 621e1a6640 Type: Proportional Metrics — full-width glyphs on the font's proportional widths, palt (#966)
A character attribute (Character panel menu with the East Asian options;
`text.setFormat` / `text.setRangeStyle {proportionalMetrics}`), saved only
when on. In horizontal type it applies `palt`; vertical type sets as before
until the vertical shaping change (`vpal`) follows.

Line-end Punctuation Half Width takes nothing more off the punctuation
`palt` re-spaced (the flush opening bracket, consecutive punctuation, the
line-end half width): the segment is shaped once more without `palt`, and
the glyphs whose advance differs are marked. Punctuation the font leaves
full width is trimmed as before. SVG writes `"palt" 1` with the
character's other features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:00:35 +07:00
lollinoo c5274beb23 #1034: scaled type shows and takes its scaled sizes
After scaling 12 pt type 2x, the Character panel still showed 12 pt and
typing 10 pt gave 20 pt type. Character controls and text commands now
work in document points: the size, leading and baseline shift shown and
set are the stored ones times the text's scale along its em height, and
horizontal scale includes the ratio of the transformed em axes. Rotation,
reflection and shear stay in the transform, so text remains editable.
Groups' text counts, keyboard increments use the same units, Shrink
Text to Fit works in document tenths, and a collapsed transform rejects
dimensional edits without touching the document.

text.getRange takes effective: true for the document-space values.
2026-10-11 07:00:34 +07:00
BFlat 611f6855ef Merge pull request #1075 from storytold/fix/1063-char-paint
#1063: a fill or stroke chosen with characters selected colours just them
2026-10-11 07:00:07 +07:00
bflatasticandClaude Opus 5.5 75c587c8a0 #1063: a fill chosen with characters selected colours just them
Choosing a fill or stroke (Color panel, Swatches, the proxies) while the
Type tool had a few characters selected painted every run of the type
object. As in Illustrator, it now goes to the selected characters only:
the runs are split at the selection's ends and the paint is placed on
the runs in between, as one undo step; a stroke gets a 1 pt weight
where it had none. With only a caret, or with explicit ids or an item,
paint.setFill/setStroke behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:59:36 +07:00
Brandon ThomasandClaude Opus 5.5 10f1c56a86 Release prep: 0.9.0 and a Linux riscv64 release build (#1071)
- Version 0.8.0 -> 0.9.0 (cargo xtask version set; Cargo.lock refreshed).
- release.yml: new linux-riscv64 job, ported from GridCraft. Cross-compiles
  riscv64gc-unknown-linux-gnu on ubuntu-24.04 against riscv64 -dev packages
  from ports.ubuntu.com (the same set the linux job installs), packages a
  tar.gz, and runs vectorcraft-cli --version under qemu-riscv64. The draft
  release job now needs it, so its tarball is uploaded and in SHA256SUMS.
- packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE /
  EMULATOR support, as in GridCraft.
- docs/releasing.md: list the riscv64 artifact.

Checked locally: cargo check -p vectorcraft -p vectorcraft-cli --target
riscv64gc-unknown-linux-gnu, cargo fmt --check, actionlint, shellcheck.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:47:38 -04:00
BFlat ab49f6af8b Merge pull request #1070 from storytold/fix/1027-ps-form-ai
#1027: PostScript-form .ai files open through their layers
2026-10-11 06:05:40 +07:00
BFlat 51e24f2944 Merge pull request #1069 from storytold/fix/1025-ai-global-colour-pattern
#1025: .ai editing data keeps sublayers with global colours and pattern fills
2026-10-11 06:05:24 +07:00
bflatastic 2048084396 #1027: PostScript-form .ai files open through their layers
A .ai in PostScript form went only to the PostScript interpreter, which
stopped on the first operator its missing prolog would define
(`Adobe_packedarray`, `Lb`, `Np`): files from Rhino and other CAD apps name
the procsets without including them.

- Such a program is its own editing data: when it has layers
  (`%AI5_BeginLayer`) and no private data, the editing-data reader reads it,
  compared with the page when the program runs, from the layers alone when
  it can't be run. If the layers can't be read either, the error says why.
- The reader takes the legacy ten-operand `Lb`, ignores flatness `i`, and
  uses the bounding box as the page when nothing else says where it is.
- Tests on synthetic files; fuzz fixture and hostile-program layers added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-11 06:04:48 +07:00
bflatasticandClaude Opus 5.5 cee73d03a3 #1025: .ai editing data keeps sublayers with global colours and pattern fills
The editing-data reader stopped on `Xk`/`XK` (global process colours) and
on `p`/`P` (pattern fills), so the file fell back to its PDF part, which has
only the top-level layers.

- `Xk`/`XK` read like `Xx`: a global swatch (not a spot ink), linked at its tint.
- `%AI3_BeginPattern` sections are read by a reader of their own into
  pattern swatches (tile from `(name) llx lly urx ury`, art in pattern space);
  `p`/`P` fill or stroke with them, placed by the operator's matrix. A pattern
  it can't read whole still makes a fill with it unreadable, as before.
- Tests on synthetic editing data; fuzz tokens and fixture extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:04:48 +07:00
BFlat 3ee9543b3f Merge pull request #1021 from redrun6767/feat/guides-on-layers
Guides: ruler guides as layer objects
2026-10-11 06:04:04 +07:00
bflatasticandClaude Opus 5.5 c183c47df3 Review: guides on hidden or locked layers, and through layer commands
- Guides on a hidden or locked layer leave the selection (Selection::prune,
  run after every edit and selection change), their Layers rows neither
  select nor drag them, and guide.move / guide.remove / guide.setLayer
  refuse them; guide.setLayer refuses a locked target layer, as dragged
  rows are refused there.
- A new guide goes where new art goes (the current layer, else the top
  shown and unlocked one; refused when there is none), so it never lands
  on a hidden layer and vanishes.
- Layer commands keep guides with their layers: Duplicate (and Alt-drag
  copies of rows) copy a layer's guides onto the copy, Merge Selected and
  Flatten Artwork put the merged layers' guides on the merged layer and
  Flatten discards a hidden layer's. One shared doc predicate for "the
  layer a guide is on"; the deleted-layer cleanup moves into the doc.
- Tests: engine tests for the above and their undo, a doc check that an
  old guide reads with no layer and none is written, and a headless
  Layers panel test of guide rows (select, drag to another layer, locked).
- Translations for the new message; docs and a ROADMAP progress line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:03:48 +07:00
redrun6767andClaude Opus 5.5 2d2e0c6992 Docs: ruler guides on layers in the parity docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:47 +07:00
redrun6767andClaude Opus 5.5 2972e2e6bf Guides: drag guide rows to another layer; put older guides on a layer
Guide rows in the Layers panel drag (the selected guides when the row is
one of them) onto any row of another layer, which lights up, and go to
that layer in one undo step (guide.setLayer takes indexes). Guides on no
layer, from files saved before guides had layers, go on the top visible,
unlocked layer when a document opens or is reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:47 +07:00
redrun6767andClaude Opus 5.5 02e93902b3 Guides: ruler guides as layer objects
A ruler guide goes on the current layer (guide.add {layer}), shows,
hides, locks and is deleted with its layer, is listed in the Layers panel
as a row that selects it, and moves to another layer (guide.setLayer).
Guides on a hidden layer neither show nor snap; those on a locked layer
can't be picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:47 +07:00
BFlat d7339804e6 Merge pull request #1020 from redrun6767/feat/panel-sets-dock-columns
UI: floating panel sets and a multi-column dock
2026-10-11 06:03:43 +07:00
bflatasticandClaude Opus 5.5 33d6d38ec8 ROADMAP: panel sets and dock columns in the progress log (#1020)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:03:26 +07:00
redrun6767andClaude Opus 5.5 53f8516cc6 Docs: panel sets and dock columns in the parity docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:25 +07:00
redrun6767andClaude Opus 5.5 db39545a85 i18n: German for the panel-set strings
German is now a complete language; add the Drag to move out of the set
tooltip and the three errors window.panel.float and window.panel.dock
gained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:25 +07:00
redrun6767andClaude Opus 5.5 aa17dd7d2c i18n: Japanese for the panel-set command errors
Japanese now translates every status and error message; add the three
messages window.panel.float and window.panel.dock gained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:25 +07:00
redrun6767andClaude Opus 5.5 b26612142f UI: dock columns scroll when their panels don't fit the window
A dock column's groups scroll under its top strip, each open panel at
least 160 points tall, instead of running off the bottom of the window.
Drop zones in a column only use the part of a group in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:25 +07:00
redrun6767andClaude Opus 5.5 a7279231cb UI: drop panels anywhere in a set or dock column, and on its tabs
A group's upper half inserts the dropped groups right above it and its
lower half right below it, in floating sets and dock columns alike, and a
dock column's room under its groups puts them at its bottom; the edge
bands were too thin to hit, and the one above a dock column's top group
lay outside the column. The tabs a drop stacks panels with now light up
over the panels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:25 +07:00
redrun6767andClaude Opus 5.5 77667945b1 UI: dock floating panel sets as dock columns
A floating set dropped on the left edge of the icon column or of a dock
column docks as a new dock column there, building a multi-column dock.
Its groups stack, take tabs and tear out as in a floating set; the strip
along its top floats the column again and its × docks its panels where
they live. Popped-out icon panels open left of the dock columns. Columns
are kept in preferences and workspaces; window.panel.dock takes column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:25 +07:00
redrun6767andClaude Opus 5.5 faa3678a3f UI: stack floating panel groups into sets
Floating panel groups dropped on another group's top or bottom edge join it
in a set: the groups stack top to bottom, move together by the top group's
title bar (whose × docks them all), and a group dragged by its tab strip
leaves the set. A double-click on a tab collapses a group to its tabs.
Sets and collapsed groups are kept in preferences and workspaces, and
window.panel.float takes below, above and collapsed.

Behaviour from public documentation:
https://helpx.adobe.com/illustrator/using/workspace-basics.html

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 06:03:25 +07:00
BFlat 2ddbd9293f Merge pull request #1035 from LloydNicholson/fix-offset-path-hang
#1028: Offset Path finishes where the sweep looped forever
2026-10-11 05:30:58 +07:00
bflatasticandClaude Opus 5.5 f5bf327447 Review: rustfmt; ROADMAP progress log (#1028)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:41 +07:00
lloydandClaude Opus 5.5 1622a70f85 #1028: Offset Path finishes where the sweep looped forever
The sweep under Offset Path (linesweeper 0.4) never finished on some
miter offsets: on the reported glyph and on a synthetic outline it kept
allocating until the app ran out of memory. linesweeper 0.5 fixes that
infinite loop ("Fixed an infinite loop", 0.5.0 changelog), and builds
without changes.

0.5 can hand back a curve piece that doesn't follow the input curve it
was cut from. In the Shape Builder rosette test, a contour runs out along
one petal and back by such a piece, which became a 59 x 4 pt hole in the
merged shape. Tidy::repair rebuilds a piece that strays from the one input
curve through both its ends as that curve's exact sub-curve (input curves
are indexed by their bounds' left edge, so it stays cheap). The there-and-
back then encloses nothing and is dropped as a sliver. Pieces on their
curve, and pieces whose ends two input curves pass through (a real lens),
are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:41 +07:00
BFlat cd59b9cc94 Merge pull request #1036 from mateususa/fix/1026-selection-transform
#1026: open filled paths hit by their fill, rotate cursor direction, flip icons, live Color Picker, Scale options in Properties
2026-10-11 05:30:35 +07:00
bflatasticandClaude Opus 5.5 1b3cf83e04 ROADMAP: the #1036 fixes in the progress log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:24 +07:00
mateususaandClaude Opus 5.5 580ada65d5 #1026: Properties › Transform reaches Scale Corners and Scale Strokes & Effects
The Transform panel has both options; the Properties panel's Transform
section had no way to them. A More Options button after the flip buttons
now opens a popup with the two (the scaleCorners / scaleStrokes prefs),
open while they are toggled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:24 +07:00
mateususaandClaude Opus 5.5 7abce8426a #1026: the Color Picker shows the colour on the selection live
With art selected the picker changed it only on OK. Each new colour now
previews paint.setFill / paint.setStroke on the selection through the
session interaction (the spec is preview: true): OK keeps the last one as
one undo step, Cancel puts the art back. Nothing previews until the colour
first changes, nor without a selection (the colour then only becomes the
default paint, on OK).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:24 +07:00
mateususaandClaude Opus 5.5 0eb1e463b9 #1026: the flip buttons' icons match their flips
Lucide names its flip icons for their mirror line: flip-horizontal-2
draws a horizontal line with triangles above and below (the top-bottom
flip). The Properties and Transform panels showed it on the left-right
flip, and the other on the top-bottom one. Swapped back, with a comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:24 +07:00
mateususaandClaude Opus 5.5 0b0ccfb35a #1026: the rotate cursor wraps the corner it is at
Beside every corner of the bounding box the curved rotate cursor bowed
up. It now bows from the box's centre toward the pointer, in 45° steps
(Cursor::RotateToward, made by Cursor::rotate_about): at the top right
it opens toward the bottom left, at the bottom right toward the top left.
The Selection tool shows it over the rotate zone and while rotating (it
follows the pointer round), and Free Transform does the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:24 +07:00
mateususaandClaude Opus 5.5 0abb4157c9 #1026: open filled paths are hit by their fill on every side
fill_contains promised to close open subpaths (as their fill does) but
counted kurbo's winding, which leaves the closing edge out: a point beside
it read as outside. An open path (an anchor deleted from a closed one) lost
its fill clicks on that side, and a reflection moved the edge across, so it
looked like Reflect broke selection. Open subpaths are now closed by a
straight line back to their start before the winding is counted; closed
paths are borrowed as they are.

Tests: the open path from the report, on both sides and even-odd; reflected
shapes, imported SVG hearts, a selected shape dragged by its fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:30:24 +07:00
BFlat 96344f0761 Merge pull request #1022 from redrun6767/feat/shift-select-guides
Guides: Shift selects ruler guides and art together
2026-10-11 04:35:33 +07:00
bflatasticandClaude Opus 5.5 a2530621fc ROADMAP: guides and art selected together (#1022)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:35:16 +07:00
redrun6767andClaude Opus 5.5 d93e4e9cf1 Docs: Shift-selecting guides with art in the parity doc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 04:35:16 +07:00
redrun6767andClaude Opus 5.5 a22bced150 Guides: Shift selects ruler guides and art together
A Shift-click on a guide adds it to the selected art (guide.select
toggle keeps the art), and Shift-adding or -removing art keeps the
selected guides; a new selection still clears them. With both selected,
Delete deletes the art and the guides in one undo step (not when ids are
given).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 04:35:15 +07:00
BFlat a9724275cc Merge pull request #1023 from redrun6767/fix/pdf-unembedded-font-style
PDF import: a font the file doesn't embed keeps the style its name says
2026-10-11 04:35:09 +07:00
bflatasticandClaude Opus 5.5 04e714e9dd ROADMAP: unembedded PDF fonts keep their named style (#1023)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:34:53 +07:00
redrun6767andClaude Opus 5.5 adad531adb PDF import: a font the file doesn't carry keeps the style its name says
A PDF that names a font without embedding it (here a standard Helvetica)
is drawn with a stand-in, so comparing its glyphs with the installed
family's styles picks whichever style happens to look most like the
stand-in: with a large Helvetica family installed, Helvetica became
Helvetica CE Medium (tests_import_fidelity::text_becomes_point_type
failed on such a Mac). Fonts without a font program now keep the face
their name resolves to; embedded fonts are still compared as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
2026-10-11 04:34:52 +07:00
BFlat c94d38e53c Merge pull request #1031 from KattKnight/link-watch-idle-wake
Links: wake for the first look at a link placed while the app is idle
2026-10-11 04:34:46 +07:00
bflatasticandClaude Opus 5.5 340200a6ec ROADMAP: links followed from the first look (#1031)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:14:12 +07:00
KattandClaude Opus 5.5 d5bc4ceeee contributors: KattKnight's display name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:10:33 +07:00
KattandClaude Opus 5.5 a3316d2383 Links: wake for the first look at a link placed while idle
The link watch wakes the app every two seconds only while the last look
found links (068a934, #967). A file placed less than two seconds after a
look that found none asked for no more frames, so while the window sat
idle nothing stamped it; a change made meanwhile was taken for the file
as first seen and never acted on. The watch now remembers the active
document's uid and revision at each look and, when they differ on a
later frame, asks for one wake when the next look is due. There is
still no per-frame walk of the links, and no two-second wakes for a
document without links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:10:33 +07:00
BFlat c6486ea50d Merge pull request #1049 from storytold/fix/1011-dxf-survey-units
#1011: DXF US survey inches, yards and miles import at their size
2026-10-11 04:09:07 +07:00
BFlat 145ae2e877 Merge pull request #1048 from storytold/fix/1010-svg-mixed-units
#1010: SVGs with one absolute and one unitless root side import unstretched
2026-10-11 04:08:51 +07:00
BFlat f76c834066 Merge pull request #1047 from storytold/fix/1009-precise-pen-join
#1009: Use Precise Cursors makes the Pen's join pointer a crosshair
2026-10-11 04:08:28 +07:00
BFlat d23973de4f Merge pull request #1046 from storytold/fix/1008-polar-zero-radial
#1008: a polar grid with no radial dividers draws none
2026-10-11 04:08:11 +07:00
BFlat bd55d66072 Merge pull request #1044 from storytold/fix/1007-gradient-reverse-midpoint
#1007: Reverse Gradient mirrors off-centre midpoints
2026-10-11 04:07:54 +07:00
BFlat 9ea05d5d45 Merge pull request #1043 from storytold/fix/1006-active-artboard-select
#1006: Select > All on Active Artboard selects on the active artboard
2026-10-11 04:07:37 +07:00
BFlat b5d25c1102 Merge pull request #1042 from storytold/fix/1005-run-broken-pipe
#1005: vectorcraft-cli run ends quietly when its reader closes early
2026-10-11 04:07:14 +07:00
BFlat c11b516d34 Merge pull request #1041 from storytold/fix/1004-connected-artboard-shot
#1004: a connected app's screenshot renders the artboard asked for
2026-10-11 04:06:52 +07:00
BFlat 2bfb0887d8 Merge pull request #1040 from storytold/fix/1003-path-text-color
#1003: MCP add_text colours type made on or in a path
2026-10-11 04:06:29 +07:00
BFlat b2800fb529 Merge pull request #1039 from storytold/fix/1002-noneditable-layer
#1002: new art is refused when no layer is shown and unlocked
2026-10-11 04:06:12 +07:00
BFlat 1b761c68c1 Merge pull request #1038 from storytold/fix/1001-style-attrs
#1001: named text styles take leading, kerning, mojikumi and other attributes left out at their defaults
2026-10-11 04:05:50 +07:00
bflatastic 683b1e9845 #1011: DXF US survey inches, yards and miles import at their size
$INSUNITS 22, 23 and 24 (US survey inch, yard and mile, as Autodesk's INSUNITS reference
lists them) had no size or name, so the drawing opened as unitless (millimetres, or inches
with $MEASUREMENT 0) and document.dxfInfo said Unitless.

They now derive from the US survey foot (1200/3937 m) like code 21, with their names.

The test reads each survey code's unit name and default scale and opens a survey-inch line at
7200.0144 pt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:47 +07:00
bflatastic a0438f73f9 #1010: SVGs with one absolute and one unitless root side import unstretched
A root such as width="96pt" height="96" took CSS pixels (0.75 pt) along x and points along y,
so a square came in 24 x 32 pt and the artboard 96 x 96 instead of 96 x 72; with a viewBox the
art was stretched too.

When either root side is absolute, both axes now take CSS pixels at 96 per inch (usvg already
parses at that resolution): the unitless or px side is the same CSS length it would be in a
browser. The root scale is one number.

The test opens width="96pt" with height 96, 96px and 72pt, and width 128 with height 72pt: a
96 x 72 artboard and a 24 x 24 square; with viewBox 0 0 32 32 a 72 x 72 square at x 12 (meet),
and with preserveAspectRatio="none" the whole artboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:43 +07:00
bflatastic b23a402df8 #1009: Use Precise Cursors makes the Pen's join pointer a crosshair
Cursor::precise listed every Pen state but PenJoin (added later, #794), so with Use Precise
Cursors on the Pen still showed its join badge over another open path's end.

PenJoin is a crosshair too under the preference.

The Pen join test now also checks the pointer over the other path's end is the crosshair with
the preference on (and PenJoin with it off, as before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:39 +07:00
bflatastic fd52ca32c7 #1008: a polar grid with no radial dividers draws none
shapes::polar_grid drew at least one radial divider (`radial.max(1)`), so radial 0, which
the command and the Polar Grid tool (Left arrow while dragging) accept, still drew a spoke.

Zero now draws none; the loop never divides by it.

The test checks 0, 1 and 2 radial dividers with no concentric ones give 1, 2 and 3 paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:35 +07:00
bflatastic 0497c9e46d #1007: Reverse Gradient mirrors off-centre midpoints
Reversal read the midpoints after Gradient::reverse had reordered the stops, so each stop
took the wrong segment's midpoint: red-to-blue with its midpoint at 20% reversed to a 50%
midpoint instead of 80%, moving the transition.

Gradient::reverse now mirrors each segment's midpoint with it, so a reversed gradient is the
same ramp the other way round; paint.editGradient's own (wrong) pass is gone, and the SVG
importer's reflect spread, which had a correct copy of the same mirroring, uses it too.

Tests: two- and three-stop gradients with off-centre midpoints sample the same reversed (t vs
1 - t) and come back after two reversals; paint.editGradient reverse turns a 0.2 midpoint into
0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:31 +07:00
bflatastic cacc9e4f36 #1006: Select > All on Active Artboard selects on the active artboard
The menu item sends no artboard, so the engine fell back to the first one even with another
active in the Artboards panel.

The frontend fills in the active artboard (the view's, as the Artboards panel and navigator
show it) for select.allOnArtboard, as it already did for Paste in Place / in Front / in Back
(#693); the two now share one block, and the index is kept within the artboards. An explicit
artboard still wins; the engine is unchanged.

The test makes artboard 2 active, then 1, and invokes the menu item each time, then passes
artboard 1 explicitly with artboard 1 active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:27 +07:00
bflatastic 33b6a6323d #1005: vectorcraft-cli run ends quietly when its reader closes early
`run` reported "Broken pipe" (on Windows "The pipe is being closed") and exited 1 when the
reader of its stdout went away, unlike `commands` and the other subcommands, which end quietly.

Its step printer now treats a closed pipe as the end of output, not an error: the remaining
steps still run (an export is still written), unprinted, and the run exits 0. Other write
errors and failing steps still fail.

The test runs document.json, a rectangle and an SVG export with stdout closed: status 0, empty
stderr, the SVG written; `run --cmd not-a-command` still fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:23 +07:00
bflatastic 148ef1870a #1004: a connected app's screenshot renders the artboard asked for
The desktop app's ui.render (MCP `screenshot` when connected) always rendered the first
artboard and ignored `artboard`, which the headless backend honours.

It now renders the 0-based `artboard` (the first without one, an error for one that doesn't
exist), as headless does. control-protocol.md and the control.rs list show the parameter.

The test drives ui.render through the control channel on a 64 x 64 and a 96 x 48 artboard:
no index and 0 give 64 x 64, 1 gives 96 x 48 (48 x 24 at scale 0.5), 2 is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:19 +07:00
bflatastic 7f17d980d4 #1003: MCP add_text colours type made on or in a path
add_text passed `color` to text.create only, and text.createInPath built its own character
style without a colour, so type on a path and path-based area type took the current fill.

text.createInPath now uses the same new-type style as text.create (size, font, style, color,
else the current fill), and add_text sends `color` on both routes.

The MCP test draws a blue rectangle (the current fill), turns it into on-path and area type
with color #ff0000, and reads red back through inspect_document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:15 +07:00
bflatastic eeb213e5a8 #1002: new art is refused when no layer is shown and unlocked
With every layer hidden or locked, the insertion layer fell back to the top layer anyway, so
drawing a shape, typing, pasting, making a graph, a flare, a grid, trim marks or a symbol put
the art on a hidden or locked layer: invisible, or unselectable by Select All. Illustrator
refuses to draw on a locked or hidden target layer (public forum answers, e.g.
https://community.adobe.com/t5/illustrator/can-t-draw-a-new-object-in-my-document/m-p/11107182).

DocState::target_parent is insertion_parent for new art, failing with "the target layer is
locked or hidden" when it would be a layer that isn't editable; those creation paths use it.
Another layer that is shown and unlocked still takes the art, as before. The message is in the
de, es, fr, it, ja, ru and uk catalogs. File > Place is left as it is (another branch reworks
it).

The test hides, then locks, the only layer: an ellipse, a polar grid, text and a paste each
fail with the message and leave the document unchanged; a new layer then takes a rectangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:11 +07:00
bflatastic e77d15c70d #1001: named text styles take leading, kerning, mojikumi and other attributes left out at their defaults
Character and paragraph styles checked a new attribute against the keys its base style
serializes, and the JSON leaves out Auto leading and kerning, no mojikumi and other defaults.
So charStyle.new / setAttrs refused leading or kerning, paraStyle.new refused mojikumi, and a
style captured from text with explicit leading failed to apply to Auto-leading text, to
duplicate or to come back out of a library.

The check now also accepts every field name the style type deserializes (read from serde's
derive), still refusing unknown keys and the file-only justify_auto.

The test creates and edits styles with kerning, leading, small caps, mojikumi and direction,
applies a captured leading style to fresh text with and without Clear Overrides, duplicates it,
uses it from a library, and checks `nope` and `justify_auto` are still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:06 +07:00
skylar taylor-barrick ed14640756 UI.9: integrate shared docking and preserve panel workflows 2026-10-10 14:49:35 -06:00
BFlat 3416bf49cd Merge pull request #1037 from storytold/fix/983-supersampled-export
#983: supersample Art Optimized edges in exports
2026-10-11 03:48:46 +07:00
bflatasticandClaude Opus 5.5 178f99d60e #983: supersample Art Optimized edges in exports
Asset Export, File › Export, Save for Web and Object › Rasterize drew
each shape with its own smoothed edge, so where two shapes met edge to
edge (Divide, Shape Builder, abutting art) both half-covered the pixels
on the line and the background showed through as a thin ghost border.

With Art Optimized (the default) these now supersample, as the option's
name in Illustrator says: the art is drawn with hard edges at four times
the size (three or two times for large images, within a 40-megapixel
budget and the renderer's 65,535-pixel side) and each block averaged
into one pixel. Pixels shared by two shapes are fully owned by one or
the other at the finer size, so no seam is left, and the outer edges
are still smooth. The canvas keeps shape-by-shape smoothing for speed;
None and Type Optimized are unchanged.

RenderOptions::supersample turns it on (exports and Rasterize set it).
A render test checks two triangles sharing a diagonal: a seam with
shape-by-shape smoothing, none supersampled, and the factor's limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:48:03 +07:00
BFlat c403e57d2a Merge pull request #1013 from Ni-zav/fix/991-shift-stepper-s20261010T1758Z-a74b
fix(widgets): apply Shift and Ctrl modifiers to numeric stepper buttons
2026-10-11 03:45:43 +07:00
BFlat e606c56064 Merge pull request #1012 from Ni-zav/fix/1000-object-mosaic-input-s20261010T1758Z-a74b
fix(dialogs): preserve integer counts in command parameter fields
2026-10-11 03:45:27 +07:00
BFlat d15bc61818 Merge pull request #988 from Ni-zav/fix/noissue-crop-box-option-s20261010T1630Z-c8d2e1
fix(crop): reject malformed crop rectangles without shifting coordinates
2026-10-11 03:45:10 +07:00
BFlat 428894df22 Merge pull request #999 from Ni-zav/fix/noissue-distort-geometry-validation-s20261010T1654Z-8d74ec
fix(distort): reject malformed warp geometry and explicit targets
2026-10-11 03:44:54 +07:00
BFlat 0cb1beaf21 Merge pull request #998 from Ni-zav/fix/noissue-path-anchor-validation-s20261010T1654Z-8d74ec
Fix/noissue path anchor validation s20261010 t1654 z 8d74ec
2026-10-11 03:44:37 +07:00
BFlat c05ad3985c Merge pull request #997 from Ni-zav/fix/noissue-anchor-selection-input-s20261010T1654Z-8d74ec
fix(selection): reject malformed anchor references without changing selection
2026-10-11 03:44:20 +07:00
BFlat 05bb1ac97f Merge pull request #996 from Ni-zav/fix/noissue-assets-explicit-targets-s20261010T1654Z-8d74ec
fix(assets): reject invalid object IDs before collecting export assets
2026-10-11 03:44:04 +07:00
BFlat 7915bfc0e1 Merge pull request #987 from Ni-zav/fix/noissue-stroke-input-atomic-s20261010T1630Z-c8d2e1
fix(stroke): reject invalid dash entries and keep defaults atomic
2026-10-11 03:43:47 +07:00
BFlat 219b9da346 Merge pull request #986 from Ni-zav/fix/noissue-rect-cut-input-s20261010T1630Z-c8d2e1
fix(cut): reject malformed rectangle cut bounds
2026-10-11 03:43:30 +07:00
BFlat 683f6439ca Merge pull request #985 from Ni-zav/fix/noissue-place-coordinates-s20261010T1630Z-c8d2e1
fix(place): reject malformed rectangle and position parameters
2026-10-11 03:43:14 +07:00
BFlat 67a0ffca4d Merge pull request #984 from Ni-zav/fix/noissue-paint-proxy-atomic-s20261010T1630Z-c8d2e1
fix(paint): preserve fill and stroke defaults when edits fail
2026-10-11 03:42:57 +07:00
bflatasticandClaude Opus 5.5 e0d494b2eb ROADMAP: the validation batch, Object Mosaic counts and stepper modifiers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:24:31 +07:00
bflatasticandClaude Opus 5.5 9e09d53fd9 Review #1013: the stepper test from #1015 compiles and hits the spinner
The headless test taken from #1015 set `RawInput::modifiers`, which egui
0.36 doesn't have, and clicked left of the spinner (the right-aligned
label's text ends before its box). Modifiers now come as
`ModifiersChanged` events, as the Appearance panel tests send them, and
the click is placed from the field's text. It also checks Ctrl/Cmd steps
a tenth. It fails with the spinner ignoring modifiers (15 pt for 24).
rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:14:00 +07:00
Nigel Ihza Firdausy 6d5fdc8a2a test(ui): Shift-click on the Stroke weight stepper steps 10 pt (#991)
The headless panel test of #1015, kept on top of #1013's fix (#1015's
own change to the spinner is superseded by it).
2026-10-11 02:06:41 +07:00
Nigel Ihza Firdausy 057c2ee2c1 fix(widgets): honor Shift on numeric spinner buttons (#991) 2026-10-11 02:06:20 +07:00
bflatasticandClaude Opus 5.5 5a9c6e2e06 Review #1012: whole numbers typed in command dialogs are integers
The PR kept a field's integer kind only while its value had been an
integer: typing 2.5 in Columns and then 3 sent 3.0, and the mosaic fell
back to 10 × 10 again. A whole number typed in any command dialog is now
an integer (`typed_number`) whatever the field held before; commands
that take any number read integers as well, and a fraction stays one.

Test: Object › Create Object Mosaic… opened from its menu item, 3 typed
over Columns and Enter makes a 3 × 10 mosaic (it made 10 × 10 before,
#1000).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:06:19 +07:00
Nigel Ihza Firdausy 130bacde12 fix(dialogs): preserve integral command parameters in numeric fields (#1000) 2026-10-11 01:59:54 +07:00
bflatasticandClaude Opus 5.5 311d75fc97 Review #988: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:58:43 +07:00
Nigel Ihza Firdausy c845c7f9e0 fix(crop): reject malformed crop rectangles without shifting coordinates 2026-10-11 01:58:31 +07:00
bflatasticandClaude Opus 5.5 2ff5688511 Review #999: corners and from through finite_numbers; rustfmt
Same checks in a third of the code; the corners destructure into the
four points instead of being indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:51:12 +07:00
Nigel Ihza Firdausy 0d697ed79d fix(distort): validate the full corner quad, source bounds and targets 2026-10-11 01:50:31 +07:00
bflatasticandClaude Opus 5.5 57050b0987 Review #998: one strict anchor parser; build fix for path.appendAnchor
The PR added `checked_anchor_from_json` next to the lenient
`anchor_from_json` and dropped path.rs's import of the latter, which
`path.appendAnchor` still called, so the engine didn't compile.
`anchor_from_json` is now the strict parser itself (handles through
`finite_numbers`), used by path.create, path.setAnchors and
path.appendAnchor alike: the Pen's appended anchor with a malformed
handle fails too instead of losing the handle. Tests: a non-boolean
`closed` and a malformed appended handle change nothing. rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:50:30 +07:00
Nigel Ihza Firdausy 743fe2521e fix(path): remove unused legacy anchor parser import 2026-10-11 01:49:22 +07:00
Nigel Ihza Firdausy 6bf2a42e22 fix(path): reject malformed anchors before creating or replacing geometry 2026-10-11 01:49:22 +07:00
bflatasticandClaude Opus 5.5 93cadc8994 Review #997: one anchor-reference parser, for Reshape area type too
`text.reshapeArea` read its anchors with the lenient `parse_refs`, so a
malformed reference among good ones moved only the good ones. It now
uses `checked_refs` as well, and `parse_refs` (no other user) goes.
Test: a mixed list fails without moving anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:48:17 +07:00
Nigel Ihza Firdausy 2f984f2e7a fix(selection): reject malformed anchor references before selection changes 2026-10-11 01:47:03 +07:00
bflatasticandClaude Opus 5.5 2f458842ae Review #996: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:46:59 +07:00
Nigel Ihza Firdausy 3b742b6c0c fix(assets): validate all explicit export object ids before collecting 2026-10-11 01:46:24 +07:00
bflatasticandClaude Opus 5.5 de17a00eb2 Review #987: test the next-object stroke with nothing selected; rustfmt
A rejected Stroke panel edit with nothing selected leaves the next
object's stroke as it was; an accepted one sets its weight and dashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:46:22 +07:00
Nigel Ihza Firdausy 059bd82676 fix(stroke): reject malformed dashes and keep defaults atomic 2026-10-11 01:45:19 +07:00
bflatasticandClaude Opus 5.5 6eeabcd742 Review #986: the rect through finite_numbers; rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:45:18 +07:00
Nigel Ihza Firdausy bfbe9e1eb6 fix(cut): reject malformed rectangle cut bounds 2026-10-11 01:44:43 +07:00
bflatasticandClaude Opus 5.5 ac6d72b44a Review #985: one parser for exact number lists; null at is no position
- `finite_numbers::<N>` (cmd/mod.rs) reads an array of exactly N finite
  numbers, nothing dropped or padded; `rect` uses it, and the rect cut,
  Free Distort and anchor handles reuse it in the PRs stacked on this one.
- `at` is parsed once (`at_param`) instead of point_param plus a second
  shape check that indexed the params; `at: null` means no position, as
  null does for the other optional params, and gets a test.
- rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:44:11 +07:00
Nigel Ihza Firdausy 004d015cfe fix(place): reject malformed rectangle and position parameters 2026-10-11 01:42:58 +07:00
bflatasticandClaude Opus 5.5 e860b01ed8 Review #984: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:36:54 +07:00
Nigel Ihza Firdausy 4c011cf42e fix(paint): preserve new-art defaults when paint edits are rejected 2026-10-11 01:36:17 +07:00
BFlat 6f7f32c64d Merge pull request #1017 from storytold/feat/brush-strokes-effects
Part of gap 2: Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e)
2026-10-11 01:24:49 +07:00
bflatasticandClaude Opus 5.5 59c890df9e Part of gap 2: Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e)
Eight raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::brushstrokes), under a new Effect › Brush
Strokes submenu between Blur and Distort in the Raster Effects section:

- Accented Edges (edgeWidth pt 1..14, default 2; edgeBrightness 0..50,
  38; smoothness 1..15, 5): the edges of the softened object painted
  towards white chalk at high brightness or black ink at low brightness;
  Smoothness also leaves the faint edges out. The shape stays as it was.
- Angled Strokes (directionBalance 0..100, 50; strokeLength pt 3..50, 15;
  sharpness 0..10, 3): light areas in right diagonal strokes, dark areas
  in left diagonal ones; 0 makes every stroke a left diagonal, 100 every
  stroke a right diagonal. Sharpness brings detail and bristles back.
- Crosshatch (strokeLength pt 3..50, 9; sharpness 0..20, 6; strength
  1..3, 1): the detail kept under pencil hatching along both diagonals,
  Strength passes of it, the edges roughened.
- Dark Strokes (balance 0..10, 5; blackIntensity 0..10, 6;
  whiteIntensity 0..10, 2): short strokes towards black in the dark
  areas, long strokes towards white in the light areas.
- Ink Outlines (strokeLength pt 1..50, 4; darkIntensity 0..50, 20;
  lightIntensity 0..50, 10): black outlines over the edges, ink hatching
  in the shadows, shadows darkened and highlights lightened.
- Spatter (sprayRadius pt 0..25, 10; smoothness 1..15, 5): each pixel
  shifted by noise up to 0.4 x the radius; Smoothness makes the grains
  larger and smoother.
- Sprayed Strokes (strokeLength pt 0..20, 12; sprayRadius pt 0..25, 7;
  strokeDirection Right Diagonal, Horizontal, Left Diagonal or Vertical):
  strokes along the direction, scattered by up to 0.3 x the radius.
- Sumi-e (strokeWidth pt 3..15, 10; strokePressure 0..15, 2; contrast
  0..40, 16): wet strokes, ink bleeding from the darks along paper
  fibres, more ink with pressure and an S-curve contrast for rich blacks.
  It works in value only: hues and saturation stay, and so does the
  object's shape (no fringe in the transparency).

A stroke averages the colour along a segment through the pixel (16
samples at most, the art softened first when they lie more than a pixel
apart); strokes lie in rows around the object's centre, each row
starting at random and each stroke shifted along itself, with streaked
noise for the bristles. Everything is in document space, so the effects
look the same at any zoom or resolution, move with the object and draw
the same every time; outsets and reaches are exact. On a 2000 x 2000
raster in release the slowest settings take 0.3-2.0 s (Crosshatch at
50 pt with 3 passes the most).

Shared helpers instead of copies: edges::sobel (Glowing Edges now uses
it, unchanged), distort::displace and texture::Tones are pub(super).

Catalogue rows with parameter docs, menu entries, Sprayed Strokes'
direction dropdown, translations (de, es, fr, it, ja, ru, uk, zh-hans,
zh-hant; menu labels in cs and pt-br), tests (parameters and clamping,
outsets and reaches, each filter and option showing, chalk and ink on
the edges only, shadows and rich blacks, Sumi-e keeping the shape and
hues, hatching keeping the tone, strokes smearing along their direction,
Spatter's radius, resolution and move invariance, canvas, multithreaded,
PDF and SVG, the command sweep with junk parameters, the menu) and
ROADMAP.

Behaviour from public documentation only:
https://helpx.adobe.com/photoshop-elements/using/brush-stroke-filters.html
https://www.computerhope.com/jargon/p/photoshop-sprayed-strokes.htm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:19:26 +07:00
Brandon ThomasandClaude Opus 5.5 d4b0a5479b Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#994)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:11:14 -04:00
BFlat 7989d1345f Merge pull request #846 from alpha-rock/codex/3d-effects
Add live Revolve effect with artboard rotation and lighting gizmos
2026-10-11 00:50:04 +07:00
bflatasticandClaude Opus 5.5 f443dde16f Review: German for Revolve, one row per catalog key, README
The Revolve dialog and gizmo strings in German (the catalog landed after
this branch was written), with Kreiseln and Aussehen umwandeln as the
catalog already names them. Intensity, which main's Texture filters
added meanwhile, is kept once per catalog. README's missing list says
what 3D still lacks beyond the initial Revolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:04:55 +07:00
alpha-rock c36b6976e3 M9: add live Revolve with artboard rotation and lighting controls 2026-10-10 23:44:46 +07:00
BFlat 01e165af63 Merge pull request #982 from storytold/fix/964-first-frame-watch
#964: start again when the first frame never reaches the screen
2026-10-10 23:40:02 +07:00
bflatasticandClaude Opus 5.5 199a464140 #964: start again when the first frame never reaches the screen
With High Performance chosen on a hybrid laptop, the window's first
frame never reached the screen: no error, no panic, the app stayed in
the background without a window and the log stopped after "rendering
with". The start-up recovery of #502 only saw failures.

A watch thread now gives the first frame 20 seconds from the app's
creation (after the files named at the start opened). When the second
frame hasn't begun by then, so the first was never presented, the app
starts again without that adapter, as it does after a graphics failure,
and this process ends. A window minimized at the start isn't counted.
When the hung processor was chosen in Preferences, the status bar's
note after the restart points to Automatic.

docs/development.md explains it and how to recover from a Graphics
Processor choice that doesn't work; ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:38:58 +07:00
BFlat 45516a6a96 Merge pull request #761 from fahrim/mcp-variables
Variables (data merge): text/visibility variables, datasets, panel
2026-10-10 23:35:20 +07:00
bflatasticandClaude Opus 5.5 d0015a496b variables: Window › Variables opens the panel
As Window's other panel items do, and as the reference's Window menu has
it, Window › Variables is the panel itself instead of a submenu of
eighteen commands. The panel menu gains Make Text Dynamic, Make
Visibility Dynamic, Unbind Variable and Delete Variable beside the
bottom bar's buttons, which share one reading of what the selection
allows; every command stays in the command palette. The labels only
the submenu used leave the catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
bflatasticandClaude Opus 5.5 2e07f887bf docs: Variables in the ROADMAP's last-updated list (#761)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
bflatasticandClaude Opus 5.5 500e19680d variables: review fixes for the panel, dialogs and translations
- Dataset and variable names are the user's: the panel's Data Set dropdown and
  the dialogs' name dropdowns show them as they are instead of translating
  them; the dialog's Kind dropdown shows translated kind names.
- Choosing the empty option in a dialog dropdown picked the first name.
- The panel reads the art once per frame for every row's italics, the dialog
  no longer clones the document's variables every frame, and slices are
  matched instead of indexed.
- "No document" and "Define a variable first" go through tl!, and the dialog
  headings Variable Options / Edit Data Set, Text and Visibility have rows in
  every complete catalog; the unused "{name} · {kind} · {count}" row is gone.
- German rows for every Variables string (the catalog predates this branch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
bflatasticandClaude Opus 5.5 b3fa281282 variables: review fixes for hidden objects, namespaces and agent params
- A visibility variable could hide an object but never show it again: dataset
  application, capture and binding refused any object that wasn't visible
  (`is_editable`), so a row that hid an object left it hidden for good and a
  capture after hiding it in the Layers panel didn't record it. They now use
  `Document::variable_target`, which ignores the object's own visibility
  (locked art and art on hidden or locked layers are still skipped).
- Renaming a variable renamed the active dataset when the two shared a name,
  leaving it pointing at a dataset that doesn't exist.
- `variable.delete` and `dataset.delete` without `names` act on the
  highlighted variable and the active dataset, so Window > Variables > Delete
  Variable / Delete Data Set no longer fail with "missing `names`".
- `ids` is checked like other commands' explicit targets (a non-list no longer
  falls back to the selection, bad ids are errors), names are trimmed and
  capped at 255 characters, and the bind checks are shared by Bind and Make
  Dynamic.
- Make Dynamic's fallback name no longer becomes " 2" for unnamed art.
- `dataset.list` reads the art once for every row's drift check, and
  `Variables::prune` looks names up in a set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
Fahri Meral f2252b0d9c variables: highlighted row owns Delete, Options and Select Bound Object
Select Bound Object ran variable.unbind on the selection, so choosing it silently
removed the bindings; Delete Variable and Delete Data Set deleted every row. Now
the panel keeps a highlighted variable row (a click highlights and selects what
it drives, as the reference panel does): Select Bound Object selects what the
highlighted variable drives via select.set, Delete Variable deletes only it, and
Delete Data Set deletes only the current set. Variable Options and Rename Data
Set open their dialogs preselected with the highlighted variable / active set
(the double-click used to invoke variable.rename with a bare name, which only
errored). Unbind enables only over a bound selection; Make Text Dynamic only for
exactly one type object.

The highlight is per-document session state with its own command
(variable.highlight, no undo step, like layer.highlight): rename carries it,
delete clears it when stale. New engine tests (18) and panel tests cover the
reviewer's regression case, verified to fail on the old wiring.
2026-10-10 23:34:21 +07:00
Fahri Meral 06dbe5b1e8 variables: put the panel in the collapsed icon column
Opening the panel from the Window menu worked, but collapsing the dock did not
show it: `ICON_PANELS` only registers a panel (so `window.panel` finds it) while
`ICON_PANEL_GROUPS` is what the collapsed column actually draws, and `variables`
was in the first list only. Sixteen panels are registered without a group; for
the ones meant to live in the column, the entry in `ICON_PANELS` alone is not
enough.

`variables` gets a row of its own at the bottom of the column, where the
reference app keeps it. Both lists now say which is which, so the next panel
does not repeat this, and a test asserts a registered panel reaches the column
(it fails without the group entry) and that every grouped id names a registered
panel with an icon.

`floating.rs`'s icon test counted the column's icons, which only means
"one left" while the column is tall enough to show them all — past that, one
scrolling into view replaces the one that left. It runs on a window tall enough
for every icon now, and checks the count is the number of registered groups'
icons rather than a number that happens to pass.

Dragging a panel into the icon column still doesn't work, and is not something
this PR changes: tearing a panel off makes it float, for every panel. Docking it
back into the column would be a dock feature of its own.

cargo xtask ci: all 7 steps pass on Rust 1.99.
2026-10-10 23:34:21 +07:00
Fahri Meral 5a638b0a80 variables: the panel does the workflow itself, and rows can be captured
The engine side was called a solid base, but the panel could only list and
apply: variables and datasets were made through commands, which most people
won't find. The reference app's Variables workflow is now what the panel does,
and every button runs a `variable.*` / `dataset.*` command, so the panel and an
agent drive the document the same way.

New commands (all also in Window › Variables, the palette and over MCP):

- `variable.makeTextDynamic` / `variable.makeVisibilityDynamic`: define a
  variable of that kind over the selection, named after what it drives, and
  bind it — one step, as the panel's two buttons.
- `dataset.capture`: record what the bound objects hold right now as a new row
  ("Data Set 1"… ) and make it active. This, rather than typing values, is how
  rows are made: change the art, then capture.
- `dataset.update`: write what the art holds now into the active row. Without
  an active row it says so rather than picking one.
- `variable.rename` / `dataset.rename` (Variable Options…, Rename Data Set…):
  a name is how a variable is referred to, so renaming carries its bindings and
  its values in every row with it.
- `dataset.list` reports `matches` per row: whether the art still holds what
  that row says.

`Document::captured_values` is the one place that reads a bound object's value,
so capture, update and the drift check can't disagree about what the art says.

Panel: the current data set across the top as a dropdown with ◀ ▶; variable
rows with their kind's icon and the name of the object they drive ("Multiple
objects" when it is more than one), a click selecting it and a double click
opening Variable Options; a dataset row in italics once an edit has moved the
art away from what it says; Make Text/Visibility Dynamic, New, Unbind and Delete
below, Capture and Update on a second line; and a (≡) menu with the same
commands for the ones that act on a row rather than the selection.

Checked on Rust 1.99 (the current stable), which is what the review asked for:
cargo xtask ci, all 7 steps pass.
2026-10-10 23:34:21 +07:00
Fahri Meral eb0f2ff2e4 variables: let one object be driven by several variables
`bindings` mapped an object id to a single variable name, so binding an object
to a second variable silently dropped the first — a binding lost with nothing
to show for it. The reference app binds the selected object to the selected
variable, one click at a time, and nowhere says a second binding replaces the
first (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/
automate-actions/work-with-variables.html), so this allows several instead.

`bindings` is now a set of variable names per object. `dataset.select` walks
them in the order the variables are defined, which keeps two text variables on
one object deterministic (the later one's value is what is left, and `applied`
counts both). `variable.bind` reports `bound` (added) and `already` (was there
already); `variable.unbind` takes an optional `variable` to drop one binding
instead of all of them.

This is done before the field ships rather than after: `variables` has never
been in a released file, so the shape can still change for free. Once a version
with it is out, the same change would need a format version bump and a
migration, since `crates/format` keeps every version from 1 loading.

A strict superset of what worked before: every binding a document had is still
there and still applied, in the same order.

cargo xtask ci: all 7 steps pass.
2026-10-10 23:34:21 +07:00
Fahri Meral 202894372a variables: fix binding lifetime, dataset editing and the missing UI to define
Review of #761 turned up three real defects and a feature that was visible
but not usable. All of it is fixed here, each defect with a regression test
that fails without the fix.

- A binding outlived the object it named. Deleting art left its entry in
  `variables.bindings` forever: it counted as a skip in every dataset
  application, `variable.list` counted it, and the saved file grew with every
  delete. `Document::prune_variable_bindings` now lets them go after each
  edit, as `prune_assets` does for Asset Export's.
- `Variables::prune` cleared `active_dataset` by comparing it against the
  *variable* names being deleted. A dataset named like a variable lost its
  active state when the variable went. The two are separate namespaces; the
  block is gone (dataset.delete already did it right).
- `variable.delete` reported the number of names asked for, not the number
  deleted, so deleting a name that was not there still said `deleted: 1`.

- `dataset.set` (new): a dataset's values could only be set when it was
  created, so changing one meant deleting and rebuilding the row — losing its
  place in the list and the active one. This replaces the row's values and
  reports what it dropped.
- Window › Variables, and dialogs for the "…" commands. Every command had an
  empty menu path and the command palette runs those with `{}`, so nothing
  could be defined from the UI at all. The commands now carry their menu
  path, the "…" ones open a Variables dialog (a name and a kind; a name and a
  field per variable, its kind's editor), and `dataset.set` opens with the
  row's own values.
- Locked art is neither bound nor rewritten by a dataset, and says so.
- `set_plain_text` told a node that existed but was not type that it was
  missing.
- The panel had Document Info's icon, so the two were indistinguishable in
  the collapsed column; it gets its own, plus New/Delete buttons, and its
  rows say what their count is.

Format round trips now cover variables (the testkit fixture defines both
kinds, binds them and applies a row), and ROADMAP/README no longer list the
feature as missing.

cargo xtask ci: all 7 steps pass.
2026-10-10 23:34:21 +07:00
Fahri Meral c89906a13a docs: note variables in ROADMAP automation row 2026-10-10 23:32:59 +07:00
Fahri Meral ba2069c057 variables: panel, window menu entry and translations 2026-10-10 23:32:59 +07:00
Fahri Meral 555b0e60d3 variables: data-merge model and engine commands 2026-10-10 23:32:59 +07:00
BFlat e3874e89e7 Merge pull request #981 from storytold/feat/distort-effects
Part of gap 2: Effect › Distort (Diffuse Glow, Glass, Ocean Ripple)
2026-10-10 23:24:59 +07:00
bflatasticandClaude Opus 5.5 8a0cd9dbf6 Part of gap 2: Effect › Distort (Diffuse Glow, Glass, Ocean Ripple)
Three raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::distort), under a new Effect › Distort
submenu between Blur and Pixelate in the Raster Effects section (the
vector Distort & Transform stays its own submenu):

- Diffuse Glow (graininess 0..10, default 6; glowAmount 0..20, 10;
  clearAmount 0..20, 15): the highlights, softened over 2 points, glow
  white from the brightness Clear Amount leaves clear; white grain as
  dense as Graininess says is sprinkled over the object, thicker in the
  glow. Transparency stays as it was.
- Glass (distortion 0..20, 5; smoothness 1..15, 3; texture Blocks,
  Canvas, Frosted or Tiny Lens, default Frosted; scaling % 50..200, 100;
  invert): the object seen through a glass surface; each pixel takes the
  colour the surface's slope bends it to, sampled between pixels. Slopes
  are taken over a span Smoothness widens. The surfaces are made in code
  (Canvas is Texturizer's weave); Load Texture is left out.
- Ocean Ripple (rippleSize 1..15, 9; rippleMagnitude 0..20, 9): smooth
  noise ripples about Ripple Size + 2 points across shift the object by
  up to 0.03 x magnitude x their size.

The shifts are capped, so the effects' outsets and reaches are exact,
and every surface lies in document space around the object's centre:
the same at any zoom or resolution, moving with the object, identical
on every redraw.

Catalogue rows with parameter docs, menu entries, the Texture dropdown,
translations (and German's missing Canvas), tests (parameters and
clamping, outsets, each texture and Invert, the ripples' reach, the glow
and its grain, resolution and move invariance, canvas, multithreaded,
PDF and SVG, the command sweep with junk parameters, the menu) and
ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:23:36 +07:00
BFlat f147eb58c7 Merge pull request #974 from IHAGI-c/fix/graph-tick-marks
Graph Type: tick mark length and count per axis, category tick marks, axis label prefix and suffix
2026-10-10 23:07:36 +07:00
bflatasticandClaude Opus 5.5 3a48de555e Review: translate the tick mark lengths; ROADMAP
Short and Full Width, the Graph Type dialog's new Length choices, in the
complete catalogs (None was there). The Value Axis dropdown keeps the
one that landed with #971. ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:49:15 +07:00
IHAGI-c d7fb5ec727 Graph Type: tick mark length and count per axis, category tick marks, axis label prefix and suffix
Each axis now takes its own tick marks, and the value axes a prefix and
suffix for their numbers:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-graph-value-axes.html

- Value axes (the left one, or a bar graph's bottom one, and the right
  one): tick marks of no, short or full length, and a count per
  division; labels stay at the divisions.
- Category axis: the same, at the labels or, with Draw Tick Marks
  Between Labels, between them (halfway between labels when they sit
  at the plot's edges).
- Add Labels: prefix and suffix per value axis ($, %).
- The Graph Type dialog offers the lengths and value axis sides as
  choices, and counts it sends back as floats are read.

Own choices, as the page doesn't say: the lengths are none, short (the
4 pt ticks as before) and full (across the plot); category axes start
with no tick marks, as they had none before. Counts are capped at 20
per division and 10,000 category tick marks, affixes at one line of 64
characters. A full-length tick on the zero line or an axis line is left
out.

The legend moves past a bar graph's last value label when a suffix
runs it into the legend's gap. New fields are written only when set;
graphs with the default options draw the same art as before.
2026-10-10 22:38:35 +07:00
BFlat 6794385731 Merge pull request #971 from IHAGI-c/fix/graph-value-axes
Graph Type: value axis on the left, right or both sides; separate scales
2026-10-10 22:35:33 +07:00
bflatasticandClaude Opus 5.5 bea68002e9 Review: Graph Type's Value Axis is a dropdown
The generic Graph Type dialog showed `valueAxis` as a text field. It now
lists On Left Side, On Right Side and On Both Sides, translated in the
complete catalogs, with a test that the choices match the command's
params and that OK moves the axis. ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:48:51 +07:00
IHAGI-c 2f97c8ab49 Graph Type: value axis on the left, right or both sides; separate scales
The value axis can now go on the left side (as before), the right side
or both sides of column, stacked column, line, area and scatter graphs:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-graph-value-axes.html

With the axis on both sides, Separate Scales gives the series assigned
to the right axis a scale of their own there, with its own tick values
(rightTicks, rightAxisMin, rightAxisMax):
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/assign-different-scales-to-value-axes.html

- A series goes on an axis with Group Selection (or `seriesIndexes`)
  and `valueAxis` left or right. Without separate scales both sides
  show the one scale.
- Each axis stacks its own stacked columns and areas; stacked columns
  of the two axes take a slot of the cluster each instead of covering
  each other.
- An axis with no series of its own shows the other one's scale. The
  right scale's zero gets a line of its own when it isn't the left's.
- The right axis' labels push the legend further right.
- With series on both axes picked, the Graph Type query returns no
  value axis, so the dialog's OK leaves each series where it is.
- Graph Data drops right-axis assignments for series the new data no
  longer has.

Own choices, as the pages don't say: bar graphs keep their value axis
along the bottom, and scatter graphs draw every series on one scale.

New fields (valueAxis, separateScales, rightSeries, rightTicks,
rightAxisMin, rightAxisMax) are written only when set, so other files
are unchanged and older versions draw the left axis as before. Graphs
with the axis on the left draw the same art as before.
2026-10-10 21:38:15 +07:00
BFlat 2b000d828e Merge pull request #934 from dwetscher/feat/i18n-german
German interface language (de)
2026-10-10 21:28:40 +07:00
bflatasticandClaude Opus 5.5 a644c818c9 Review: German for the features landed since the catalog was written
Pixelate, Texture and Video raster effects, Libraries groups, search,
import and export, Update Changed Links, System Title Bar, Scatter, Art
and Pattern Brush Options with Colorization, and the PSD messages, in
the catalog's own conventions. ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:28:15 +07:00
dwetscherandClaude Opus 5.5 194968bc27 German interface language (de)
Add the German catalog: every menu string, every `tl!` literal, the plural
forms and the status and error messages (3,818 rows), written from the meaning
of the English labels in the vocabulary German-speaking users of vector
illustration software know (Zeichenfläche, Farbfelder, Kontur, Fläche, Pfad,
Ankerpunkt, Ebene, Verlauf, Deckkraft). Conventions are in the header of
`de.tsv`.

- Register `de` ("Deutsch") in `i18n::LANGUAGES` as a complete language; every
  `de-*` locale (`de-DE`, `de-AT`, `de-CH` …) resolves to it.
- Tests: German joins the menu-label, menu-string, `tl!`-literal and message
  coverage checks; `german_reads_as_german` pins the core vocabulary, the
  plurals, a nested message, the joined "…format" style names and the theme
  context ("Erscheinungsbild" vs the Appearance panel's "Aussehen"); ä ö Ä Ö ß
  are checked in the bundled UI fonts. The "unsupported locale" cases use
  Dutch now that German is supported.
- README, ROADMAP and docs/development.md list German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:28:15 +07:00
BFlat 58bb72ffc6 Merge pull request #969 from rafi612/system-title-bar
Compact app bar: 32 pt bar, PhotoCraft search icon, no app menu + System title bar option
2026-10-10 21:26:34 +07:00
bflatasticandClaude Opus 5.5 6315086f96 Review: System Title Bar only where it applies, docs, title repaint
- Preferences hides System Title Bar on macOS and the web, where it did
  nothing (it fell through to the plain checkbox row), and lists it with
  the window options instead of under Language.
- The next-launch note reuses "Applies the next time VectorCraft starts."
  instead of a second wording in every catalog.
- A new window title asks for one more frame: AppKit lays the title bar
  out again for it, and the next frame centres the window buttons (#968).
- The menu bar drops the bold branch for the VectorCraft menu it no
  longer shows; theme is imported where the tests use it.
- Docs: Language is Preferences › User Interface › Language (VectorCraft
  › Language on macOS); the control protocol describes System Title Bar;
  ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:15:21 +07:00
rafi612 2e643b0e76 Make the menu bar more compact 2026-10-10 21:01:38 +07:00
rafi612 bd895a1de7 Remove the in-window VectorCraft menu (File first, Exit quits, Mac unchanged) 2026-10-10 21:01:38 +07:00
rafi612 12f8a77a00 User Interface › System Title Bar: native decorations on Windows/Linux, file in OS title 2026-10-10 21:01:38 +07:00
BFlat 9f659195c3 Merge pull request #972 from storytold/fix/968-traffic-lights
#968: centre the macOS window buttons on the app bar
2026-10-10 20:56:34 +07:00
bflatasticandClaude Opus 5.5 258d61ced0 #968: centre the macOS window buttons on the app bar
AppKit places close, minimize and zoom in a standard title bar, shorter
than the 44-point app bar they sit over, so they rode high on it. They
are now moved to the middle of the bar, inset from its left as far as
from its top, with the title bar container made as tall as the bar so
they stay clickable. AppKit lays the title bar out again on resize and
when leaving full screen, so the placement runs every frame and sets a
frame only when it is out of place. The app bar keeps the room the
buttons take, measured, instead of a fixed 78 points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:56:18 +07:00
BFlat 5ff1b97376 Merge pull request #970 from storytold/fix/962-inner-glow-source
#962: Inner Glow's Source is a Center/Edge dropdown
2026-10-10 20:45:36 +07:00
bflatasticandClaude Opus 5.5 9216c47204 #962: Inner Glow's Source is a Center/Edge dropdown
The effect dialog showed Inner Glow's `source` as a text field, so the
only way to pick Center was to type the parameter value. It now lists
Center and Edge like the other fields that pick one of a few values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:32:47 +07:00
BFlat 4eaf5e7b50 Merge pull request #967 from storytold/feat/link-watch
Links: Update Links applies while a document is open (from #956)
2026-10-10 20:20:24 +07:00
bflatasticandClaude Opus 5.5 068a934f6a Links: one watch for changed linked files
The link watch checks the active document's linked files every two
seconds and as the window comes back to the front, so the separate check
on focus that #925 added (missing_links::after_focus and its asked-files
set) goes: with both, a change was acted on twice. The watch also
walks the document's links only when a look is due, not every frame, and
wakes the app every two seconds only while the document has links. The
doc comments of status and links::groups are back on their functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:19:27 +07:00
KattandClaude Opus 5.5 079580257a Links: Update Links applies while a document is open
Linked files another app changes while their document is open now follow
Preferences > File Handling > Update Links, as they do when it opens:
Automatically reads them again (an Update Links undo step), Ask When
Modified offers to (a question worded for files changed on disk while
working), Manually leaves them modified in the Links panel.

The desktop app stamps the active document's linked files (size +
modification time) on a worker thread every two seconds and when its
window comes back to the front; no look starts while a dialog is open.
Stamps are kept per document; a change is acted on once; a file still
being written is tried again. links.updateChanged reads changed files
again regardless of the preference (CLI, MCP).

New strings translated in all ten catalogs; ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:19:27 +07:00
BFlat f81c0f51d7 Merge pull request #959 from IHAGI-c/fix/graph-combine-types
Graph Type: combine graph types, one type per series
2026-10-10 20:09:10 +07:00
IHAGI-c 01a65c44ab Graph Type: combine graph types, one type per series
Graph Type on series picked with Group Selection (or listed in the new
`seriesIndexes` param) now sets the type of those series only, so a
column graph can carry a line series:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/combine-different-graph-types.html

- Column, stacked column, line and area mix on the vertical value axis;
  bar and stacked bar on the horizontal one. Scatter, pie and radar take
  no other type. The help page doesn't list which types mix, so this is
  our own choice: types that share the value axis.
- Stacked columns take one slot of the column cluster (a graph of
  stacked columns only is unchanged); stacked series stack with each
  other, areas with each other, and the value axis covers both totals.
- Areas draw at the back, lines in front of columns. Category labels,
  areas and lines sit at the category centres once a series that isn't
  edge to edge is in the graph.
- With only series selected, the Graph Type query returns their type,
  so the dialog's OK keeps it, and they stay selected after the change.
  An explicit `id`, or anything else in the selection, is the whole
  graph, as before.
- A series given the graph's own type follows the graph again. A type
  the graph's type can't take is drawn as the graph's and kept.

Series types are stored in a new `seriesKinds` field, written only when
a series has its own type; versions that don't know it draw the whole
graph as the graph's type. Graphs of one type draw the same art as
before.
2026-10-10 20:08:33 +07:00
BFlat 11c3a99bfb Merge pull request #960 from storytold/feat/texture-effects
Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
2026-10-10 19:44:02 +07:00
bflatasticandClaude Opus 5.5 30ba774e6f Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
Six raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::texture), under a new Effect › Texture
submenu between Sharpen and Video in the Raster Effects section:

- Craquelure (crackSpacing pt 2..100, default 15; crackDepth 0..10, 6;
  crackBrightness 0..10, 9): the object on relief plaster that cracks into
  plates about Crack Spacing apart (a Voronoi network over jittered points)
  and along the quarter, half and three-quarter contours of its softened
  tones. Depth sets how dark and wide the cracks are and how high the
  plates' bevels and the tones stand (lit from the top left); Brightness
  how brightly the surface is lit.
- Grain (intensity 0..100, 40; contrast 0..100, 50 = unchanged; grainType
  Regular, Soft, Sprinkles, Clumped, Contrasty, Enlarged, Stippled,
  Horizontal, Vertical, Speckle; default Regular): coloured 1-point grains,
  smooth ones, clumps, 2-point grains, streaks along rows or columns, dark
  specks; Sprinkles and Stippled use the background colour, white.
- Mosaic Tiles (tileSize pt 2..100, 12; groutWidth 1..15, 3; lightenGrout
  0..10, 9): irregular tiles bevelled at their edges, with sunken grout half
  a point wide per step, lightened as Lighten Grout says.
- Patchwork (squareSize pt 0..10, 4; relief 0..25, 8): squares filled with
  the colour around their centres, raised to heights that follow the
  highlights and shadows, a little more or less at random.
- Stained Glass (cellSize pt 2..50, 10; borderThickness 1..20, 4;
  lightIntensity 0..10, 3): single-coloured panes leaded in the foreground
  colour (black, half a point per step), lit from behind at the object's
  centre and fading out towards its corners (PixelSpace gains `extent`).
- Texturizer (texture Brick, Burlap, Canvas, Sandstone, default Canvas;
  scaling % 50..200, 100; relief 0..50, 4; lightDirection Bottom, Bottom
  Left, Left, Top Left, Top, Top Right, Right, Bottom Right, default Top;
  invert): the object on a surface in relief. The four surfaces are height
  fields generated in code (bricks in stretcher bond, plain weaves coarse
  and fine, octaves of smooth noise); no image files. Load Texture is left
  out.

Every pattern lies in document space around the object's centre, so the
result is the same at any zoom or raster resolution, moves with the object
and is identical on every redraw (hash noise of cell indices); slopes are
taken in document units. Work per pixel is bounded (a 5 x 5 cell search,
four or five height samples, box blurs); parameters are clamped to their
ranges and finite-checked. The Pixelate helpers (Place, the cell search,
the softened colours) are shared, Mezzotint uses the shared smooth noise,
and the effect dialogs' dropdowns translate their labels in an "effect"
context where the plain word means something else (Soft, Horizontal and
Vertical are type settings in Japanese and Chinese).

Catalogue rows with parameter docs, outsets and reaches, menu entries,
dropdowns (Grain Type, Texture, Light Direction), translations, tests
(parameters and clamping, cracks and contours, ten distinct grains, tiles
and grout, squares in relief, leaded panes and the centre light, the
textures lit and inverted, the same at twice the resolution and when
moved, canvas, multithreaded, PDF and SVG, the command sweep with junk
parameters, the dialogs and the menu) and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:31:47 +07:00
BFlat 32a688d68b Merge pull request #953 from crqch/fix-contributor-links
Fix contributor links
2026-10-10 18:50:11 +07:00
BFlat 5d07d646a9 Merge pull request #950 from seldoms/i18n/chinese-translations
i18n: wrap hardcoded UI strings for Chinese localization
2026-10-10 18:40:44 +07:00
bflatasticandClaude Opus 5.5 22443d8711 Keep CJK UI fallback fonts to ui_fonts
The app already finds an installed font for CJK (and other) characters
the UI fonts lack, the first time a frame paints one (ui_fonts): reading
a system CJK font at every startup duplicated that, cost a font file's
read and memory for interfaces that never show CJK, and broke its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:15 +07:00
Michał Kosior crqch a0cc4a4b94 Fix contributor links 2026-10-10 13:35:36 +02:00
seldoms f0349fb2f8 i18n: wrap hardcoded UI strings for Chinese localization
- Wrap remaining hardcoded UI labels and buttons with tr()/tl!() calls
- Add missing Simplified and Traditional Chinese translations
- Add entries to other language catalogs for consistency
2026-10-10 18:27:56 +07:00
BFlat d428de6caa Merge pull request #952 from storytold/fix/949-artboard-label-width
Fix #949: artboard names fit their artboard when zoomed out
2026-10-10 18:25:02 +07:00
bflatasticandClaude Opus 5.5 6a13088840 Fix #949: artboard names fit their artboard when zoomed out
Artboard names are drawn at a fixed screen size above each artboard, so
zoomed out, where artboards shrink on screen, the names ran past them
and over their neighbours. A name is now no wider than its artboard on
screen, ending in an ellipsis when longer, and an artboard narrower
than 24 px shows none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:24:50 +07:00
BFlat 18c9d27c8d Merge pull request #947 from IHAGI-c/fix/graph-blank-values
Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
2026-10-10 18:16:26 +07:00
BFlat 3b32ea9626 Merge pull request #946 from rschuerer/fix/861-draw-non-native-art
Part of #861: draw non-native art in .ai editing data from its PDF
2026-10-10 18:13:52 +07:00
bflatasticandClaude Opus 5.5 42b198e3cb Graph data: constants after the imports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:59:47 +07:00
IHAGI-c 35c4a428ff Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
A graph's data can now hold blank cells: an empty CSV cell, a null in
`rows` (any cell that isn't a finite number), the rest of a short row,
or a row that has only a label.

- Columns, bars and stacked segments leave a blank cell out instead of
  drawing a zero-height mark.
- Line and scatter graphs break their lines around it and draw no
  marker there; the value axis ignores it.
- Area, pie and radar graphs draw it as 0, which is what it adds to
  them.

The help pages don't describe blank cells, so this is our own choice:
a cell with no value has nothing to draw, which also matches how a
series with a missing year reads in a report.

In the file a blank cell keeps a 0 in `rows` and is listed in a new
`blanks` field ([category, series] pairs, written only when there are
any). A version that doesn't know blanks ignores the field and reads
the zeros, so files still open there, and files without blanks are
unchanged.

A number in straight quotes is a label, as the help page on graph
labels says (a year as a category name, for one). The CSV reader used
to strip the quotes first and read it as a value. Label columns are now
decided for the whole column: once any row starts with a label, every
row's first cell is its label, so a row with an empty label no longer
shifts its values one place left. The data window writes a header row,
labels that are empty, read as numbers or have spaces around them in
quotes, and blanks as empty cells, so they come back as they were.
graph.setData {} returns blanks in `rows` as null, so the rows it
returns can be edited and sent back. A label-only row keeps its place
with blank values; a row with neither a value nor a label is dropped,
and a first row that is a label followed by empty cells (`Year,,`) is
still read as a header with no series names.

The data grid is built in full for drawing and for the data window, so
it now stops at 10,000 categories of 256 series, which keeps a file
with one very long row from allocating without bound.

Behaviour (quoted labels): Add graph labels and data sets:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-labels-and-data-sets.html
2026-10-10 17:59:45 +07:00
bflatasticandClaude Opus 5.5 1446b4f5dc Keep the doc comment of unreadable on unreadable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:58:55 +07:00
Reinhard SchuererandClaude Fable 5.1 79dd751d3b Draw non-native art in .ai editing data from its PDF
A /ForeignObject (non-native art: the content of a placed PDF, which
Illustrator shows but doesn't edit) was left out with a note. The
letterhead of #861 keeps its fan of swooshes that way: a radial gradient
in a 3-point PDF, scaled 271 times and clipped by the swoosh paths, so
the page lost its main graphic.

The reader now decodes the object's ASCII85 /Data, has the PDF importer
read the PDF, and fits its art to the object's box as a group named
"Non-native art": /Bounds measured from /Origin with y down, then
/RTransform, which lands exactly on the clipping paths around both
objects of that file; the PDF's content is kept in its own y-up
coordinates, so it is flipped into the box. Ids are the document's and
the PDF's swatches stay unlinked; its images join the document by
content key. A PDF that can't be read is left out with a note, as
before.

The EPS crate now uses the PDF crate (same layer; allowed in the layer
check like render and metafile).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch
2026-10-10 17:58:55 +07:00
BFlat 7d6be31711 Merge pull request #948 from storytold/feat/brush-options-scatter-art-pattern
Part of gap 5: Scatter, Art and Pattern Brush Options
2026-10-10 17:57:25 +07:00
bflatasticandClaude Opus 5.5 2bc6c66c94 Part of gap 5: Scatter, Art and Pattern Brush Options
Double-clicking a Scatter, Art or Pattern brush in the Brushes panel (or the
panel menu's Brush Options...) now opens its options dialog, as Calligraphic
and Bristle brushes already did. OK runs brush.options and every stroke
painted with the brush redraws.

- Scatter Brush Options: Name; Size (10-1000 %), Spacing (10-10000 %),
  Scatter (-1000-1000 %) and Rotation (-180-180 deg), each Fixed (the first
  value), Random (between the two values) or Pressure (the first value at
  the lightest pen pressure, the second at the heaviest); Rotation relative
  to Page or Path. Scatter brushes now keep these modes (`modes`); brushes
  saved before keep their look (Random where the two values differ).
- Art Brush Options: Name; Width (1-1000 %); Scale Proportionately, Stretch
  to Fit Stroke Length or Stretch Between Guides with the guides' Start and
  End (% of the art length); the four Directions; Flip Along and Across.
- Pattern Brush Options: Name; Scale (1-10000 %), Spacing (0-10000 %); the
  five tiles (Outer Corner, Side, Inner Corner, Start, End) drawn with their
  art or None; Flip Along and Across; Stretch to fit, Add space to fit or
  Approximate path.
- All three: Colorization (None, Tints, Tints and Shades, Hue Shift with its
  Key Color picked from the art's colours, the most used one by default) and
  a live preview of a stroke painted with the edited brush.

Brush definitions from files and commands are now kept within these ranges
(Brush::sanitize, also for Calligraphic and Bristle values); unknown modes,
directions, fits or colorization methods are refused. The brush.options and
ui.brushOptions docs list the new params and fields. Tests: the brush model
(modes, pressure, sanitizing, art colours), engine params reaching the
brush and strokes redrawing, junk params in command_sweep, and headless
dialog tests for each dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:48:05 +07:00
BFlat 5b198f3c00 Merge pull request #941 from IHAGI-c/fix/graph-line-options
Graph Type: Edge-to-Edge Lines for line graphs; radar follows Mark and Connect Data Points
2026-10-10 17:32:01 +07:00
bflatasticandClaude Opus 5.5 471c47f96a Graph Type: Edge-to-Edge Lines start off, as the reference app's do
New line graphs put their points at the centres of their categories, the
option's default in the reference app; graphs saved before the option
existed were drawn edge to edge and keep it (a field default of its own).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:31:33 +07:00
IHAGI-c bb778a50aa Graph Type: Edge-to-Edge Lines for line graphs; radar follows Mark and Connect Data Points
Line graphs take the Edge-to-Edge Lines option (`edgeToEdge` on
graph.setType). On, the lines run across the whole plot, which is how
they always drew and stays the default, so existing graphs and files
look the same. Off, the points sit at the centres of their categories,
like the columns of a column graph, so a line graph lines up with a
column graph of the same size. The page describes only what the option
does when on; the off position is its opposite.

Radar graphs drew their ring and markers whatever Mark Data Points and
Connect Data Points said. They now follow both, like line and scatter
graphs.

Behaviour: Format columns, bars, and lines, Graph Type options for
line, scatter and radar graphs (Mark Data Points, Edge-to-Edge Lines,
Connect Data Points):
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-columns-bars-and-lines.html
2026-10-10 17:31:33 +07:00
BFlat 7241726e56 Merge pull request #940 from IHAGI-c/fix/graph-axis-override
Graph Type: Override Calculated Values uses the exact value-axis range
2026-10-10 17:23:59 +07:00
BFlat 3bdaa45607 Merge pull request #945 from storytold/docs/readme-status-1010
Docs: README status for 2026-10-10
2026-10-10 17:22:12 +07:00
bflatasticandClaude Opus 5.5 b4c89f7301 Docs: README status for 2026-10-10 (PSD and PSB open and place)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:21:50 +07:00
BFlat 20c1a1621e Merge pull request #944 from storytold/fix/938-corner-widgets-multi
Fix #938: Live Corner widgets on every selected path
2026-10-10 17:20:05 +07:00
bflatasticandClaude Opus 5.5 d57134a308 Fix #938: Live Corner widgets on every selected path
The corner widgets only showed when the selection was exactly one path.
Now every selected path that has them shows its widgets: live rectangles
and polygons with the Selection tool, any path with Direct Selection,
each with its own corners (all of a path selected whole, the
Direct-Selected ones otherwise). Dragging any widget rounds every shown
corner of every path in one step, Alt-click cycles their kind, and the
red limit outline covers them all; a double-click opens Corners for the
path it was on. Each widget is drawn in its own layer's colour.

object.setLiveShape takes items: [{id, corners?}], several objects each
with its own corners, which the drag sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:19:52 +07:00
BFlat 9449c92136 Merge pull request #943 from storytold/feat/926-library-groups
Fix #926: the Libraries panel's search, groups and library export/import
2026-10-10 17:18:20 +07:00
bflatasticandClaude Opus 5.5 d62ecfe33a Fix #926: the Libraries panel's search, groups and library export/import
Search: a search field at the top of the Libraries panel filters the
current library's colours, character and paragraph styles and graphics
by name, ignoring case; groups without a match are hidden, groups with
one open, and a search nothing matches says so ("No results").

Groups: items can be put in user-named groups, kept in the library file
(`groups: [{name, items: [{kind, item}]}]`, left out when there are
none, so files written before groups load as they are). New commands:
library.createGroup {library?, name?, items?}, library.renameGroup
{library?, group, name}, library.deleteGroup {library?, group} (its
items stay, ungrouped) and library.moveItem {library?, kind, item,
group?}. An item is in one group at most; deleting an item takes it out
of its group. The panel shows each group as a collapsible section with
its item count after the ungrouped items (rename and delete on its
context menu), each item's context menu has Move to Group (No Group,
the groups, New Group...), and a New Group button sits in the footer.
library.get lists the groups, library.list counts them. The generic
command dialog takes fixed params in `__params` for this.

Export / import: library.export {library?, path?} writes a library
(colours, styles, graphics with art and thumbnails, groups) as a
.vclibrary file, the JSON the library folder keeps (without a path:
{data}); library.import {path | data | dataBase64, name?} adds one as a
new library, made current, numbered on a name clash ("Brand (2)").
The panel menu has Import Library... and Export Library..., through the
app's file dialogs (the web downloads, and picks through File > Open,
which now lists Libraries files). Imported and folder files are made
safe: at most 10,000 items of a kind and 1,000 groups, names clipped and
made unique, sizes finite, thumbnails kept only when their PNG header
says at most 1024 px a side, group entries naming missing items or
unknown kinds dropped; a file over 512 MB or that isn't a library is an
error. Paths go through the engine's file helpers (automation roots).
The importer is fuzzed (import_fuzz: garbage and mutated libraries).

Also fixed: the third graphic added to a library got the second's id
(`graphic-2` twice).

Docs: the commands' params docs, docs/mcp.md, ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:17:01 +07:00
BFlat c738400ebb Merge pull request #942 from storytold/feat/video-effects
Part of gap 2: Effect › Video (De-Interlace, NTSC Colors)
2026-10-10 17:06:09 +07:00
bflatasticandClaude Opus 5.5 c71af3079c Part of gap 2: Effect › Video (De-Interlace, NTSC Colors)
Adds the Video group to the raster effects, after Sharpen:

- De-Interlace…: Eliminate Odd or Even Fields, Create New Fields by
  Duplication or Interpolation. The fields are the rows of the document's
  raster grid (Document Raster Effects Settings › Resolution, carried to
  the filters as PixelSpace::line) counted from the top of the page, so
  the result is the same at any zoom and moves with the page. A line
  taken out copies the line above (the one below at the top), or takes
  the average of the two.
- NTSC Colors (no options, applied at once): colours a composite
  television signal can't carry, with luma plus chroma past 110 % of white
  or luma minus chroma below -20 % in YIQ, have their chroma cut to fit,
  keeping their luma. Saturated yellow, cyan and pure red lose some
  saturation; legal colours stay as they are.

Catalogue rows with parameter docs, dialog dropdowns, menu, translations,
tests (effects, canvas, command sweep junk, menu) and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:05:56 +07:00
BFlat 7fb2c47006 Merge pull request #939 from storytold/fix/811-identify-unnamed-glyphs
Fix #811: unnamed glyphs of an installed font stay in their type
2026-10-10 16:46:54 +07:00
bflatasticandClaude Opus 5.5 a61aa93c1c Fix #811: unnamed glyphs of an installed font stay in their type
In a PDF (or the PDF part of an .ai file), a glyph of a subset font that
no ToUnicode map, CID table or character map of the font program names
was drawn as outlines, so one text object came in split: "RADA" as the
type "ADA" plus the R's outline.

Such a glyph is now looked for in the installed face of the font's name:
the first character (of those single-byte fonts encode: ASCII, Latin-1,
Latin Extended-A, punctuation, the euro and trade mark signs, fi and fl)
whose glyph draws the same outline, point for point within 2/1000 em,
names it. A subset carries the font's own outlines, so an installed copy
of the same font finds every glyph. Other versions of the font, empty
glyphs and glyphs that are no character still keep their outlines, with
the warning as before. Results are cached per glyph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:46:39 +07:00
BFlat 552945f4e7 Merge pull request #936 from storytold/fix/927-direct-marquee-type
Part of #927: Direct Selection marquees take the type they touch
2026-10-10 16:19:59 +07:00
bflatasticandClaude Opus 5.5 7bf002af2b Part of #927: Direct Selection marquees take the type they touch
A Direct Selection (or Group Selection) marquee only gathered the
anchors of paths, so type, images, symbols and other objects without
anchors to pick were never selected by it. They are now selected whole
when the marquee touches them, as with the Selection tool, next to the
path anchors it holds; locked and hidden objects stay out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:19:44 +07:00
BFlat 3da4080b5e Merge pull request #935 from storytold/fix/918-place-psd
Fix #918: PSD and PSB files open and place as their merged image
2026-10-10 16:09:58 +07:00
bflatasticandClaude Opus 5.5 0eb4ce5e98 Fix #918: PSD and PSB files open and place as their merged image
File › Place and File › Open read PSD and PSB files: the merged image
every such document carries after its layers, which is what a linked PSD
shows. A placed PSD links like any image, so the Links panel updates and
relinks it, and it is sized by its ResolutionInfo resource.

The reader is written from the public file format specification. It
reads Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK (through the colour
settings' CMYK) and Lab, at 1, 8, 16 and 32 bits per channel (32-bit
values are linear), raw, PackBits or zipped with or without prediction.
A document with transparency stores its merged image on white with the
alpha in the first extra channel. It comes back with straight colours,
also for 16- and 32-bit files, which say so in their Lr16/Lr32 block.
Multichannel files are refused with a message. The file is untrusted:
every read is bounds-checked and the image's size is checked against
the decode budget before anything is allocated.

PSB is a read-only format of its own, so exports never write a PSD under
a .psb name. The desktop packaging still doesn't associate either
extension with VectorCraft (UNASSOCIATED_EXTS): they belong to raster
editors. Layers (import options) are left for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:09:41 +07:00
BFlat be51213f0d Merge pull request #929 from haranishi/fix/eps-gradient-stop-cap
EPS import: bound the stitched-gradient builder
2026-10-10 16:06:43 +07:00
BFlat fe2d2ca173 Merge pull request #933 from storytold/fix/921-properties-gradient
Fix #921: the Properties panel's Gradient section with Type and Draw
2026-10-10 15:51:05 +07:00
bflatasticandClaude Opus 5.5 b3ed7df2a4 Fix #921: the Properties panel's Gradient section with Type and Draw
With the Gradient tool active, the Properties panel shows a Gradient
section: the type buttons (Linear, Radial, Freeform) and, for a
freeform gradient, Draw: Points or Lines as radio buttons. The Gradient
panel and the Properties panel share the type row and the draw mode
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:50:23 +07:00
BFlat 93fb184939 Merge pull request #932 from storytold/fix/924-tab-leader-typing
Fix #924: the Tabs panel's Leader and Align On keep what is typed
2026-10-10 15:50:20 +07:00
bflatasticandClaude Opus 5.5 2ef32bb1a2 Fix #924: the Tabs panel's Leader and Align On keep what is typed
Both fields read their text from the tab stop again every frame, so each
keystroke was lost before Enter or a click away could apply it: the
field seemed to clear itself while typing. They now use the panels' text
field, which keeps the text being typed until it is committed. The
leader keeps spaces (". " for spaced dots), up to eight characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:49 +07:00
BFlat 5ccbbcca3f Merge pull request #931 from storytold/fix/925-links-on-focus
Fix #925: linked files changed in another app update when the window comes back
2026-10-10 15:49:46 +07:00
IHAGI-c 0044143880 Graph Type: Override Calculated Values uses the exact value-axis range
With both axisMin and axisMax set, the value axis now runs exactly from
one to the other, split into the tick count (5 when automatic). Before,
the override went through the same rounding as the calculated axis, so
asking for 0 to 12000 drew 0 to 12500 and the columns came out shorter
than the data. With only one bound set the axis is still calculated
around the data, as before.

Ticks are now counted from the minimum instead of added up, so the last
tick lands on the maximum (an offset range like 1e10..1e10+1 lost it to
rounding) and a step too small for the range can't loop forever. An
override too wide for f64 used to give a NaN axis and an internal error
in graph.setType ("min > max, or either was NaN"); it now falls back to
the calculated axis.

graph.setType {} also reports axisMin and axisMax.

Behaviour: Change graph value axes, Tick Values > Override Calculated
Values (minimum, maximum and divisions):
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-graph-value-axes.html
2026-10-10 17:49:19 +09:00
bflatasticandClaude Opus 5.5 11beb8fff1 Fix #925: linked files changed in another app update when the window comes back
Preferences › File Handling › Update Links only acted when a document
opened, so a linked image edited in another app while VectorCraft stayed
open kept its old pixels until Update Link in the Links panel, even with
Automatically chosen.

When the window comes back to the front, the active document's links
are now checked. With Automatically, changed files are read again; with
Ask When Modified, they are offered for update, once per change of a
file (keyed by path, size and modification time, since two saves can
share a time); with Manually, the Links panel shows them as modified.
Nothing is asked while a dialog is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:08 +07:00
BFlat 04eb5e52c8 Merge pull request #930 from storytold/feat/pixelate-effects
Part of gap 2: Effect › Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize)
2026-10-10 15:47:10 +07:00
bflatasticandClaude Opus 5.5 9bad0a24ca Part of gap 2: Effect › Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize)
Four Photoshop-style raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::pixelate), under a new Effect › Pixelate
submenu between Blur and Sharpen in the Raster Effects section:

- Color Halftone (maxRadius 4..127, default 8; channel1..channel4 screen
  angles -360..360, defaults 108/162/90/45): each colour channel is
  screened at its angle in square cells sized so a dot of Max. Radius
  fills one; each cell's dot covers the share of the cell that is the
  channel's coverage-weighted mean there (white stays white, black
  black). RGB documents screen R, G, B (channels 1 to 3); CMYK documents
  screen C, M, Y, K inks (channels 1 to 4), also when they are drawn ink
  plane by ink plane (the K plane uses channel 4).
- Crystallize (cellSize 3..300, default 10): every pixel takes the
  colour at the nearest of one jittered point per cell (exact Voronoi
  over a 5 x 5 neighbourhood, pruned by distance).
- Mezzotint (type: Fine/Medium/Grainy/Coarse Dots, Short/Medium/Long
  Lines, Short/Medium/Long Strokes; default Fine Dots; a dropdown in the
  dialog): each channel is turned fully on or off against its own random
  threshold pattern, so the object becomes pure colours.
- Pointillize (cellSize 3..300, default 5): one dot of random size per
  cell at a jittered point, coloured by the softened object there and as
  opaque as the object at its centre, stacked in random order over a
  white canvas where the object is.

Lengths are in points (Scale Strokes & Effects scales them) and every
pattern lies in document space around the object's centre, so the
result is the same at any zoom or raster resolution, moves with the
object, and is identical on every redraw (hash noise of the pattern's
cell indices, no state). Work per pixel is bounded whatever the
parameters, which are clamped to their ranges and finite-checked; the
halftone's cell grid is capped by the raster's size and a screen finer
than 3 pixels leaves the image as it is (its average). PixelSpace gains
`channels` so the renderer tells the filters what the channels hold.

Catalogue rows with parameter docs, outsets and reaches for cropping,
menu entries, dialog labels (Max. Radius, Channel 1-4, Cell Size), the
Mezzotint Type dropdown, translations, and tests: parameters and
clamping, tone and coverage, CMYK inks, polygon crystals, pure-colour
mezzotints with ten distinct patterns, dots on a white canvas, the same
result at twice the resolution and when moved, the canvas, PDF and SVG
through the engine, the command sweep with junk parameters, the dialogs
and the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:42:24 +07:00
BFlat 2d6014583a Merge pull request #923 from callmeBalloch/fix/artboard-artwork-movement
Fix artwork movement when editing artboard X/Y coordinates
2026-10-10 15:29:47 +07:00
Ikki NishiharaandClaude Sonnet 5.5 de3f53b24d EPS: bound the stitched-gradient builder
Cap the stops taken from a stitching function (1024), bound linear_points by
depth and by a shared visit count, and stop eval from copying the whole
Functions array on every call.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:29:45 +09:00
bflatasticandClaude Opus 5.5 f3f5e38bb6 ROADMAP: the #922 note joins the Last updated list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:06:59 +07:00
Rick 934b65b42a M11: honor Move Artwork in artboard coordinate edits 2026-10-10 09:49:24 +02:00
BFlat e1599a16aa Merge pull request #799 from vbasky/feat/graph-series-palette
Colour graph series through Group Selection
2026-10-10 14:37:23 +07:00
bflatasticandClaude Opus 5.5 f21f8a89ce Graph series: find series art in one pass and ignore indices past the series
Painting looked up each selected object's graph by walking up through
parent_of, a search of the whole document per level, so a fill or stroke
change on a large selection grew with the square of the document. The
series art of every graph is now mapped in one pass, and documents with
no graph skip the lookup.

A series index comes from the file: one past the graph's series grew the
graph's series paints to that length. Such a group is no longer taken
for a series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:33:41 +07:00
Vikram Bhaskaran 5c9ffc69e8 Colour graph series through Group Selection
Group Selection's fill and stroke on a graph series are kept on the graph,
separately per series, and reapplied when its data, type or size changes,
and through save, open and undo.
2026-10-10 14:33:40 +07:00
BFlat dd61dfa146 Merge pull request #920 from storytold/feat/885-transform-effect-dialog
Part of #885: the Transform effect's dialog takes Transform Each's controls
2026-10-10 14:32:55 +07:00
bflatasticandClaude Opus 5.5 20e5a746bd Part of #885: the Transform effect's dialog takes Transform Each's controls
Effect › Distort & Transform › Transform… was a plain list of fields.
It now has Transform Each's layout: Scale and Move with sliders, Rotate
with an angle dial, then Copies, Reflect X and Y, the reference point and
Random, kept with the effect so editing it from the Appearance panel
shows them again.

- The effect scales, rotates and reflects about the chosen point of the
  9-point grid (`reference`, the centre by default).
- Random takes a share of each value, drawn from the object's id: each
  object varies its own way and keeps its result on every redraw; a
  group's members move together.
- Copies stop before one reaches past 1e9 pt, and the scale and move are
  capped, so large values can't overflow.
- Transform Each and the effect share the sections and options code; the
  angle dial is a new widget.
- A plain number field now takes the focus when it is a dialog's first
  field, so both dialogs open with Scale › Horizontal ready for typing.

Transform Objects, Transform Patterns and Scale Strokes & Effects are not
in the effect yet: the geometry effects reshape paths only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:32:10 +07:00
BFlat da73a46137 Merge pull request #919 from storytold/feat/832-automation-roots
Fix #832: --automation-read-root and --automation-write-root confine the MCP server's files
2026-10-10 14:28:11 +07:00
bflatasticandClaude Opus 5.5 9f875050ef Fix #832: --automation-read-root and --automation-write-root confine the MCP server's files
vectorcraft-cli mcp and vectorcraft --control take the flags PhotoCraft
takes. While roots are in force, every path a command reads (open, place,
relink, SVG image links, library and profile loads, plug-ins, preset
imports, font searches) must lie inside the read root and every path it
writes (save, export, package, Export for Screens and for Web, print to
file, captures, library saves) inside the write root. Paths are made
absolute, links are followed (a missing file's nearest folder's), and the
result is compared by whole components, ignoring case on Windows; `..`
below a missing folder, dangling links and device names are refused. A
root left out grants none of its access; without the flags nothing
changes.

The checks live in one place, vectorcraft_engine::file_access, consulted
by the engine's file helpers and the app's file services. The headless
server confines its whole process; the app confines control requests,
the frames carrying input they inject and the background saves they
start, leaving the person at the keyboard and the app's own folders
alone. The screenshot tool now has the backend write its file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:27:24 +07:00
BFlat ab69087d68 Merge pull request #915 from Messhias/feat/394-display-print-size
Part of #394: Display Print Size at 100% Zoom drives Actual Size
2026-10-10 14:26:31 +07:00
bflatasticandClaude Opus 5.5 77e2a30c6d Part of #394: Actual Size shows print size whatever the display scaling
Display Print Size at 100% Zoom divided an assumed 96 pixels per inch by
the display's scale factor, so on a scaled display (150%, Retina) a
document inch came out a fraction of an inch. Nothing measures the
screen's real density, so an inch is now 96 screen points, the reference
density display scaling is set against, at any scale factor. The unused
screen_ppi and pixels_per_point fields go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:25:50 +07:00
Fabio William Conceição a7faeb4299 Part of #394: Display Print Size at 100% Zoom drives Actual Size. 2026-10-10 14:25:44 +07:00
BFlat 90818cae40 Merge pull request #910 from Messhias/feat/394-font-names-in-english
Part of #394: Show Font Names in English labels the font menus
2026-10-10 14:25:15 +07:00
Fabio William Conceição 89812275d7 Part of #394: Show Font Names in English labels the font menus. 2026-10-10 14:10:31 +07:00
SawizzleandClaude Opus 5.5 e15ef47094 docs: register the MCP server from an installed release (#856)
* docs: register the MCP server from an installed release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop the third-party link; the Windows path is the default install folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop the third-party link; the Windows path is the default install folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop the third-party link; the Windows path is the default install folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:08:20 +07:00
BFlat 3ab2af0744 Merge pull request #917 from storytold/fix/909-profile-preview-scale
Fix #909: a Width tool profile's preview fits the Profile menu
2026-10-10 13:53:54 +07:00
bflatasticandClaude Opus 5.5 8d2a3f48a1 Fix #909: a Width tool profile's preview fits the Profile menu
The Profile menu's preview drew a stroke's width profile assuming its
widths stay within the stroke weight. A profile made with the Width tool
can be many times wider, so its silhouette spilled over the Stroke panel.
It is now scaled to the preview by its widest point and clipped to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:52:41 +07:00
BFlat 71003386ac Merge pull request #916 from storytold/fix/900-segmented-field
Fix #900: spinner fields read as one box in three parts
2026-10-10 13:32:13 +07:00
BFlat bd439727dd Merge pull request #914 from storytold/feat/812-tool-options
Part of #812: polygon properties, Flare Tool Options, more tool double-clicks
2026-10-10 13:23:32 +07:00
BFlat 809c4266a4 Merge pull request #913 from storytold/fix/901-shift-click-target
Fix #901: Shift-click a target circle in the Layers panel to add or remove it
2026-10-10 13:15:31 +07:00
BFlat 994e821f53 Merge pull request #912 from storytold/fix/903-reselect-after-deselect
Fix #903: Reselect repeats a Same command after Deselect
2026-10-10 13:15:17 +07:00
bflatasticandClaude Opus 5.5 2543da7cf0 Fix #900: spinner fields read as one box in three parts
A number field with a spinner and a preset list (stroke weight, font
size, leading…) was three boxes side by side: the field came out a
point taller than the spinner and the chevron, and each joint had two
borders. The field is now exactly as tall as the other controls of a
row, with its text centred, and the three parts share their borders.
num_field draws its box with the panel text fields' code instead of a
copy of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:13:41 +07:00
bflatasticandClaude Opus 5.5 990d869897 Part of #812: more tools open their options on a double-click
Double-clicking a tool's button (or tool.options {tool}) now also opens:
- Artboard tool: Artboard Options of the active artboard (the Artboard
  tool's while it is in use, else the window's);
- the graph tools: Graph Type for the selected graph (an error without
  one), the dialog Object > Graph > Type opens;
- the Symbolism tools: Symbolism Tools Options with the brush the eight
  tools share (Diameter, Intensity, Symbol Set Density);
- Magic Wand: the Magic Wand panel.

The Object > Graph dialogs' opener is shared with the toolbar. The new
labels are translated in the eight complete catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
bflatasticandClaude Opus 5.5 ae27d899b8 Part of #812: Flare Tool Options last and open from the toolbar
The Flare tool keeps its options (Center: Diameter, Opacity, Brightness;
Halo: Growth, Fuzziness; Rays: on/off, Number, Longest, Fuzziness; Rings:
on/off, Path, Number, Largest, Direction) as persistent tool options,
saved with the preferences. The next flare dragged out uses them (the drag
still sets the centre and diameter, and Up/Down the ray count), a click
with the tool opens them filled in, and double-clicking the tool (or
tool.options {tool: "flare"}) opens them to edit: OK keeps them and draws
nothing. Before, the click dialog was the generic parameter form with
fixed defaults and the drag always used the command's defaults.

The new Flare Tool Options dialog lays the four groups out in two
columns; Rays and Rings have their checkboxes, and every value is kept
within its range. Its labels are translated in the eight complete
catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
bflatasticandClaude Opus 5.5 7c0ce8a382 Part of #812: polygon properties in the Properties and Transform panels
A selected live polygon now shows Illustrator's Polygon Properties in the
Transform panel and the Properties panel's Transform section: side count,
angle, corner type and radius, radius, side length and Make Sides Equal
(enabled once an uneven scale has made its sides differ). Before, only the
side count and corner radius were there.

object.setLiveShape takes polygonAngle (degrees counterclockwise),
polygonRadius and sideLength (points, centre to vertex / side) and
makeSidesEqual, which drops the uneven scale and shear and keeps the
polygon's centre, angle and mean radius. Each call is one undo step; a
radius or length that isn't a number above 0 is an error. A star stays a
plain path, as in Illustrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
BFlat 5305b80308 Merge pull request #899 from vbasky/credit/vbasky-name
Credit vbasky by real and display name
2026-10-10 12:56:25 +07:00
bflatasticandClaude Opus 5.5 980cc4fe44 Fix #901: Shift-click a target circle in the Layers panel to add or remove it
Clicking an object's target circle in the Layers panel always replaced
the selection, so several objects couldn't be picked there. Shift-click
now adds the object (a layer: its art) to the selection, or takes it out
when it is selected already, as in Illustrator; nothing stays targeted
then. layer.target takes add: true for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:55:38 +07:00
bflatasticandClaude Opus 5.5 6917984329 Fix #903: Reselect repeats a Same command after Deselect
Select › Reselect repeated the last selection command, but a Select Same
command needs a selected object to compare with, so after Deselect it
failed with "nothing selected". Reselect now remembers the objects that
were selected when the command ran and, with nothing selected, takes the
reference from those still in the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:55:37 +07:00
BFlat ba2fc64d89 Merge pull request #906 from storytold/feat/852-pressure-brushes
Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
2026-10-10 12:38:10 +07:00
bflatasticandClaude Opus 5.5 5cf9233d26 Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
Double-clicking a brush in the Brushes panel (or the panel menu's Brush
Options…) opens its options. Calligraphic Brush Options: Angle,
Roundness and Size, each Fixed, Random or Pressure with its Variation,
over live previews of the nib and of a stroke. Bristle Brush Options:
Shape and the bristle sliders. OK runs brush.options; strokes painted
with the brush update.

The Paintbrush records the pen pressure along a stroke (path.freehand
takes [x, y, pressure] points), and a Calligraphic brush with Pressure
variation changes its nib along the stroke, from the value less the
variation at the lightest pressure to the value plus it at the heaviest.
Brushes saved before keep their Random variation. Pen pressure doesn't
reach the app on macOS yet (winit 0.30 reports none there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:15:18 +07:00
Vikram Bhaskaran c81f120ece Credit vbasky by real and display name in the About window 2026-10-10 16:03:48 +11:00
BFlat db23e80ec4 Merge pull request #897 from storytold/fix/886-shift-anchor-guides
Fix #886: Smart Guides keep working on a Shift-constrained anchor drag
2026-10-10 12:00:28 +07:00
BFlat da010c33a1 Merge pull request #896 from storytold/fix/888-smooth-wheel-zoom
Fix #888: wheel notches zoom in a glide with Zoom with Mouse Wheel
2026-10-10 12:00:22 +07:00
bflatasticandClaude Opus 5.5 e47d542680 Fix #886: Smart Guides keep working on a Shift-constrained anchor drag
Dragging anchors with Direct Selection snapped to points, paths and
Smart Guides, but with Shift held the move kept its 45° angle and
snapped to nothing. The grabbed anchor now slides along the constrained
line onto the anchors, paths and alignment lines that cross it, as the
drawing tools' Shift does (counted from General › Constrain Angle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:49:56 +07:00
bflatasticandClaude Opus 5.5 d8759492df Fix #888: wheel notches zoom in a glide with Zoom with Mouse Wheel
With General › Zoom with Mouse Wheel, each wheel notch's zoom was
applied on the frame it arrived, so the view jumped a step per notch
while scrolling glides over a few frames. A mouse wheel's notches now
glide in (what is left shrinks by e every 50 ms), ending at the same
zoom; a trackpad's fine steps still apply at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:49:54 +07:00
BFlat 6ba5e0585e Merge pull request #895 from storytold/fix/867-macos-dialog-thread
Fix #867: macOS file dialogs no longer run inside the window's event handler
2026-10-10 11:49:20 +07:00
BFlat 4c1e16d791 Merge pull request #894 from storytold/fix/890-artboard-edge-art
Fix #890: artboard art includes art flush with its right or bottom edge
2026-10-10 11:49:13 +07:00
bflatasticandClaude Opus 5.5 dd8f144770 Fix #867: macOS file dialogs no longer run inside the window's event handler
On macOS the Open and Save panels ran modally on the UI thread, inside
the window's event handler. Resizing the panel delivered window events
to that handler again, and the app crashed when the mouse was released.
They now open from a thread of their own, as on Linux (#592): rfd runs
the panel on the main thread from the run loop, outside the handler, and
the app picks the answer up when it comes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:30:33 +07:00
bflatasticandClaude Opus 5.5 6c131431ed Fix #890: artboard art includes art flush with its right or bottom edge
The art that goes with an artboard (Duplicate Artboard and Copy/Cut with
art, the Artboard tool's Move/Copy Artwork with Artboard, and moving an
artboard by its X and Y) was found with a half-open containment test, so
an object whose right or bottom edge lay exactly on the artboard's was
left behind. Its edges now count as inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:30:32 +07:00
BFlat 8ae59bf6b7 Merge pull request #892 from storytold/fix/787-precise-export
Fix #787: raster exports keep translucent edges over opaque art opaque
2026-10-10 11:29:36 +07:00
BFlat b47f611e18 Merge pull request #891 from storytold/fix/798-curvature-any-path
Fix #798: the Curvature tool edits any selected path
2026-10-10 11:29:31 +07:00
BFlat 02d02f660f Merge pull request #889 from storytold/pr/roadmap-batch-2026-10-10
Docs: ROADMAP for the 2026-10-10 fixes; Other Library lists color books
2026-10-10 11:12:50 +07:00
bflatasticandClaude Opus 5.5 4099ebf3c9 Fix #787: raster exports keep translucent edges over opaque art opaque
The renderer composited in 8-bit integers, so where a partly transparent
anti-aliased edge crossed opaque art (or a background colour) the result
came out at alpha 254 on some pixels, and PNG, TIFF and WebP files were
written with an alpha channel. Raster exports (and Save for Web) now
composite in floating point (vello_cpu's f32 pipeline, enabled as a
feature), offscreen groups, masks and patterns included; the canvas keeps
the faster 8-bit path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:12:13 +07:00
bflatasticandClaude Opus 5.5 1db82cbc02 Fix #798: the Curvature tool edits any selected path
The Curvature tool only edited the path it was drawing. It now edits any
selected path, whatever drew it, keeping its shape except where edited:
a click on a point makes it the current (direct-selected) point and a
drag moves it, re-curving only its two segments (the neighbours keep
their handle directions); Alt-click or double-click toggles it smooth or
corner and Delete removes it; a click on a segment adds a smooth point
without changing the shape, and a drag moves it in the same undo step.
With an end of an open path current, clicks go on from it (the old
segments keep their shape) and a click on the other end closes it.

The edits go through a new path.curvatureEdit command (move, insert,
extend, close), path.convertAnchor and path.removeAnchor, one undo step
each. The tool now claims Delete/Backspace while it has a point to
remove, so the key no longer deletes the whole path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:12:10 +07:00
bflatasticandClaude Opus 5.5 898c4f7677 Docs: ROADMAP for the 2026-10-10 fixes; Other Library lists color books
The ROADMAP's last-updated line names this batch's fixes and the two
community PRs that didn't add themselves (#792, #765); the platforms
paragraph says Windows starts on Direct3D 12 and loads no Vulkan driver
unless asked (#806); the guides row has ruler guides with Smart Guides
on and the hover labels (#812). Window › Swatch Libraries › Other
Library's description lists .acb color books (#831).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:52:27 +07:00
BFlat 6b42a9100c Merge pull request #887 from storytold/fix/812-hover-labels
Part of #812: Smart Guides label an object's centre and anchors on hover
2026-10-10 10:46:53 +07:00
BFlat 1b398978ba Merge pull request #884 from storytold/fix/827-raster-effect-blend
Fix #827: shadows and glows keep their blend modes in PDF
2026-10-10 10:46:45 +07:00
BFlat 449387a62e Merge pull request #883 from storytold/fix/812-guides-snap
Part of #812: ruler guides pull drawn points with Smart Guides on
2026-10-10 10:46:28 +07:00
BFlat 0782972b88 Merge pull request #882 from storytold/fix/785-step-references
Part of #785: run and command_batch steps can use earlier results
2026-10-10 10:46:10 +07:00
BFlat 07708f6cca Merge pull request #881 from storytold/fix/861-open-error-dialog
Part of #861: a file the user can't open is reported in a dialog
2026-10-10 10:46:01 +07:00
BFlat 5b26fd4d01 Merge pull request #879 from storytold/fix/831-acb-color-books
Fix #831: color books (.acb) open as swatch libraries
2026-10-10 10:45:54 +07:00
BFlat a05a6d06bf Merge pull request #878 from storytold/fix/833-isolation-locks-outside
Fix #833: isolation mode locks and dims the art around the group
2026-10-10 10:45:48 +07:00
BFlat fd59778c8f Merge pull request #877 from storytold/fix/775-properties-paragraph
Fix #775: the Properties panel's Paragraph section justifies and hyphenates
2026-10-10 10:45:43 +07:00
BFlat 06d61296f5 Merge pull request #876 from storytold/fix/789-align-to-everywhere
Fix #789: every align button aligns to the Align To choice
2026-10-10 10:45:36 +07:00
BFlat fb4b84c4b9 Merge pull request #875 from storytold/fix/835-gradient-stop-recent
Fix #835: a recent colour recolours the selected gradient stop
2026-10-10 10:45:30 +07:00
BFlat 9c0eccb067 Merge pull request #874 from storytold/fix/862-stop-popover-beside-art
Fix #862: the gradient stop popover opens beside the art
2026-10-10 10:45:22 +07:00
BFlat 2b01ebcc9e Merge pull request #873 from storytold/fix/826-rotated-rulers
Fix #826: rulers cover their length in a rotated view
2026-10-10 10:45:15 +07:00
BFlat 447fe10edf Merge pull request #872 from storytold/fix/839-sfw-pixel-size
Fix #839: Save for Web accepts a typed width or height
2026-10-10 10:45:09 +07:00
BFlat 9cc0ca866d Merge pull request #871 from storytold/fix/824-artboard-label
Fix #824: the Artboard tool names its artboard once
2026-10-10 10:45:03 +07:00
BFlat 56b3119288 Merge pull request #870 from storytold/fix/806-no-vulkan-on-windows
Fix #806: Windows doesn't load Vulkan drivers unless asked
2026-10-10 10:44:58 +07:00
BFlat 0b249c5257 Merge pull request #869 from storytold/fix/758-skip-hopeless-prune
Part of #758: .ai import doesn't weigh text objects when the layers can't be used
2026-10-10 10:44:52 +07:00
BFlat c1bcd0afeb Merge pull request #863 from storytold/fix/755
Fix #755: blurred type no longer shifts as the view moves
2026-10-10 10:44:41 +07:00
BFlat 5d5db434ea Merge pull request #860 from storytold/fix/830-app-quit-pending
Fix #830: the control channel's app.quit says when it asks first
2026-10-10 10:44:34 +07:00
BFlat 84ae8e90c3 Merge pull request #859 from storytold/fix/828-effect-dropdowns
Fix #828: Offset Path's Joins and Zig Zag's Points are dropdowns
2026-10-10 10:44:27 +07:00
BFlat 71d8881186 Merge pull request #753 from storytold/feat/637-type-from-text-document
Part of #637: area type, threaded type and type on a path from the text document
2026-10-10 10:44:20 +07:00
bflatasticandClaude Opus 5.5 fdb7d7a183 Part of #812: Smart Guides label an object's centre and anchors on hover
With Smart Guides on, hovering the Selection tool near an object's centre
or one of its anchors now labels it ("center", "anchor"), as
Illustrator does, and a press there grabs the object by that point, so
the point follows the pointer and snaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:38 +07:00
bflatasticandClaude Opus 5.5 99671cabd8 Fix #827: shadows and glows keep their blend modes in PDF
A raster effect painted below its object (Drop Shadow, Outer Glow) is
written as an image under the object's vector art. The image was
rendered over nothing and placed with Normal blending, so a Screen glow
or a Multiply shadow came out as Normal in the PDF. Each image is now
composited with its effects' blend mode, one image per mode when an
object's shadows and glows use several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:04 +07:00
bflatasticandClaude Opus 5.5 522c1dabd6 Part of #812: ruler guides pull drawn points with Smart Guides on
Drawing with Smart Guides on ignored the ruler guides: only with Smart
Guides off did Snap to Point use them (a vertical guide seemed to work
when it lay on the artboard's centre line, which Smart Guides follow).
With Snap to Point on, the ruler guides now pull drawn and dragged
points with Smart Guides on too, also while a point slides along a
construction guide or Shift's angle, as in Illustrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:03 +07:00
bflatasticandClaude Opus 5.5 b767e850ed Part of #785: run and command_batch steps can use earlier results
A run (vectorcraft-cli run, the MCP command_batch tool) had no way to use
an id an earlier step returned. A parameter string that is exactly
$N.path or $last.path now takes that value from step N's result
(0-based), e.g. {"id":"$1.id"}; $$ starts a literal $, and a
reference to nothing fails the step. Objects given to a command are now
used as given: an ids element or id that isn't an object id (or an
unresolved reference) fails instead of the command acting on the
selection. text.setRangeStyle styles the selected type object when no
id is given, as text.setStyle does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:03 +07:00
bflatasticandClaude Opus 5.5 c19873e220 Part of #861: a file the user can't open is reported in a dialog
Opening a file that fails (File › Open, Open Recent, the Home screen, a
drop, the Finder or the command line) only set the status bar's
message, which is easily missed: it looked as if nothing happened. A
dialog now says "Can't open “name”." with the reason, as well. An
agent's file.open {path} still just answers with the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:59 +07:00
bflatasticandClaude Opus 5.5 9a0183a02d Fix #831: color books (.acb) open as swatch libraries
Color books such as the Pantone books a user owns (.acb) couldn't be
opened. They are now read (never written), from public descriptions of
the format: their RGB, CMYK or Lab colors, named with the book's prefix
and suffix ("PANTONE 185 C"), as spot colors unless the book marks
them as process colors, leaving out the pages' unnamed padding. They
open from Window › Swatch Libraries › Other Library…, File › Open and
the user library folder like .ase files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:58 +07:00
bflatasticandClaude Opus 5.5 b426859b15 Fix #833: isolation mode locks and dims the art around the group
While a group or layer was isolated, clicks, hovers and drags still
reached the art outside it, and that art drew as usual. Now only the
isolated container's contents hit: the art around it is locked and
clicks pass through it. On the canvas it draws at half opacity, above
the group as well as below, each run of it as one dimmed layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:57 +07:00
bflatasticandClaude Opus 5.5 61d372bb7f Fix #775: the Properties panel's Paragraph section justifies and hyphenates
With type selected, the Properties panel's Paragraph section had only
Align Left, Center and Right. It now has the Paragraph panel's seven
alignments (the justified ones too), its Hyphenate checkbox and a "…"
button that opens the Paragraph panel, as Illustrator's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:56 +07:00
bflatasticandClaude Opus 5.5 82dc5f8182 Fix #789: every align button aligns to the Align To choice
Align to Key Object and Align to Artboard worked only from the Align
panel's own buttons: the Control bar's and the Properties panel's align
buttons always aligned to the selection (one object to the artboard).
They are now one set of buttons that follow Align To (and the key
object), and the Properties panel's Align section gets the "…" button
that opens the Align panel for Distribute and Align To.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:54 +07:00
bflatasticandClaude Opus 5.5 79c81d754c Fix #835: a recent colour recolours the selected gradient stop
In the gradient stop popover (and the Color panel), clicking a recent
colour replaced the whole gradient with that colour, while the sliders,
spectrum and Black and White chips recoloured the selected stop. The
recent colours now go through the Color panel's target like the chips.
The Swatches panel's recent colours still paint the fill or stroke, as
its swatches do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:54 +07:00
bflatasticandClaude Opus 5.5 97318c188b Fix #862: the gradient stop popover opens beside the art
Opened from a stop on the Gradient tool's annotator, the colour popover
appeared just below and right of the stop, over the very art whose
colour it changes. It now opens beside the selected art, on the first
side with room for it (right, left, below, above), and beside the stop
only when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:53 +07:00
bflatasticandClaude Opus 5.5 94141eca82 Fix #826: rulers cover their length in a rotated view
With Rotate View, each ruler measured document x (or y) along a line
that crossed both axes: its ticks covered only a short stretch and the
numbers bunched up. Each ruler now measures along its own edge in the
canvas's coordinates turned with the view, so it is ticked over its
whole length at every angle, and reads x and y as before at 0°.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:53 +07:00
bflatasticandClaude Opus 5.5 5df4ddd4b6 Fix #839: Save for Web accepts a typed width or height
The dialog's W and H fields send decimal numbers (300.0) and the settings
wanted a whole u32, so the preview failed with "invalid type: floating
point". Pixel sizes now take any number and round it to whole pixels;
negative, huge and non-numeric sizes are still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:52 +07:00
bflatasticandClaude Opus 5.5 021a9474f8 Fix #824: the Artboard tool names its artboard once
With the Artboard tool, the active artboard's name was drawn twice: by
the canvas, as for every artboard, and by the tool, in blue and a little
higher. The canvas now leaves that one to the tool, which draws it where
the canvas names the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:52 +07:00
bflatasticandClaude Opus 5.5 de564bc24f Fix #806: Windows doesn't load Vulkan drivers unless asked
Creating a Vulkan instance loads every installed Vulkan driver into the
process, and a faulty one (Intel's igvk64.dll) crashed VectorCraft
before its window appeared. On Windows the window now starts with
Direct3D 12, falling back to OpenGL, as PdfCraft does; WGPU_BACKEND
(e.g. vulkan) still chooses others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:51 +07:00
bflatasticandClaude Opus 5.5 495694e1ac Part of #758: .ai import doesn't weigh text objects when the layers can't be used
Before deciding to use the layers of an Illustrator file, the import
drew them again for each text object made from the file's text
document, to see whether the page draws it. When the layers differ from
the page by more than the check allows even with every such object
gone (taking one out only changes what is drawn in its box), the
layers aren't used whatever is taken out: that weighing is now
skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:50 +07:00
bflatasticandClaude Opus 5.5 584f08ae70 Fix #755: blurred type no longer shifts as the view moves
Gaussian Blur and Feather drew through a filter layer whose blur works on
a coarser grid tied to the canvas: the blurred art moved by up to about
a pixel, and its steps showed, as the view moved by parts of a pixel
(scrolling, zooming about the pointer). They now blur the content's own
pixels on the CPU, as the Photoshop-style effects already do: the same
look wherever the view is, cached while only the view moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:49 +07:00
bflatasticandClaude Opus 5.5 db8be89052 Fix #830: the control channel's app.quit says when it asks first
Raw app.quit closed the window and answered null at once, so a client
couldn't tell that a save prompt was waiting. It now runs the menu's
Quit: {"pending": "saveChanges"} while a modified document is asked
about, as file.close and file.closeAll answer and the protocol
documentation says; otherwise null, and the window closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:47 +07:00
bflatasticandClaude Opus 5.5 1ba987ee7a Fix #828: Offset Path's Joins and Zig Zag's Points are dropdowns
The effect versions of Offset Path, Zig Zag and Roughen showed Joins and
Points as free text fields. They now pick Miter, Round or Bevel and
Smooth or Corner, as Object > Path > Offset Path does. A size's
Relative / Absolute (Roughen, Zig Zag, Tweak) is a pair of radio
buttons, and the effect dialogs' other on/off options are checkboxes
that show when they are off too (they were invisible unchecked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:46 +07:00
bflatasticandClaude Opus 5.5 3198abe2fd Part of #637: area type, threaded type and type on a path from the text document
Illustrator EPS and .ai type is made whole from the file's text
document, shown or hidden: point type, area type (a story in several
frames as threaded type that flows when the file opens) and type on a
path, with its fonts, size, leading, tracking, scaling, baseline shift,
fill and stroke colours (grey, RGB or CMYK), alignment, indents and
paragraph spacing. The page's drawing of that type, often in pieces, is
left out; where the text document can't say, type that shows still
comes from the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:46 +07:00
BFlat 1c19c27fef Merge pull request #765 from vbasky/pr/raster-effect-gallery
Add Stylize > Glowing Edges raster effect
2026-10-10 10:17:44 +07:00
bflatasticandClaude Opus 5.5 b9b880a5de Review fixes for #765: the glow outside a shape keeps the edge's colour
The output multiplied the glow's colour by its opacity a second time, so outside the shape (where
the glow sets the opacity) it darkened as it faded and showed as a grey rim over a light
background. The test now checks that the fading glow keeps the edge's colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:05 +07:00
Vikram Bhaskaran 60831b87d9 Preserve edge color and brightness through glow blur 2026-10-10 09:50:05 +07:00
bflatasticandClaude Opus 5.5 800baacbba Glowing Edges: transparency beyond the right and bottom edges; rustfmt; as_chunks in tests
The edge filter read `y * w + x` for x = w (the next row's first pixel)
and y = h at the raster's right and bottom edges; it now reads
transparency there, as the module documents. A test checks a solid square
glows alike on every side. Formatted with rustfmt, and the test uses
as_chunks for clippy 1.99.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:05 +07:00
Vikram Bhaskaran 11aaf816d0 M9.2: replace Find Edges with Glowing Edges 2026-10-10 09:50:05 +07:00
Vikram Bhaskaran 2d371849df M9.1: add Find Edges raster effect 2026-10-10 09:50:05 +07:00
BFlat d28cbacbf7 Merge pull request #777 from shmuelbiller1/fix/package-transitive-placed-links
Fix/package transitive placed links
2026-10-10 09:49:28 +07:00
bflatasticandClaude Opus 5.5 2762f61e83 Review fixes for #777: report sections per document, fmt, docs and ROADMAP
The report kept a placed document's LINKED FILES and FONTS inside its parent's list. Each document
now gets its own sections, the packaged one first and unchanged (no Document: line), then each
placed document's, headed by its file. Drops a debug_assert and an unneeded mut, runs rustfmt, and
documents the nested packaging in docs/mcp.md and the ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:32:12 +07:00
bflatasticandClaude Opus 5.5 94f27299d4 Merge origin/main into #777
package.rs: the collector takes main's font lookup (fonts found by any of their names, in the
version the type names, a missing style shown in its closest one, files named by packaged_name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:23:25 +07:00
BFlat 6fed61b4db Merge pull request #792 from dotCooCoo/feat/missing-fonts
Fonts: missing fonts are listed when a document opens, with Find Fonts and a folder search
2026-10-10 09:18:20 +07:00
bflatasticandClaude Opus 5.5 09a0ff23ad Review fixes for #792: build expected test paths with the platform's separator
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:07:53 +07:00
BFlat 1090385507 Merge pull request #774 from David-T-Campos/fix/svg-drop-uses-open-path
Use File Open path for native SVG drops
2026-10-10 08:50:21 +07:00
dotCooCoo 11cf254a07 Fonts: a folder search finds .suit suitcases and skips font files over 256 MB
Add Fonts adds only files a folder search found.

- macOS declares .suit as the extension of a font suitcase
  (com.apple.font-suitcase), and the font scan reads a suitcase
  whatever its name. A search considered only suitcases without an
  extension. It now also considers files whose extension is .suit, in
  any letter case, and reads one only when it is a suitcase font.
- Add Fonts copies at most 256 MB a file. A search also read larger
  font files, and a search that found one for a font could end before
  it found a smaller copy. A search now skips font files over 256 MB.

Docs: docs/mcp.md.
2026-10-10 08:42:32 +07:00
dotCooCoo 7b7e10e82e Fonts: a folder search skips suitcase fonts whose resource fork is over 8 MB
A folder search reads at most 8 MB of each font file. The suitcase
reader reads a suitcase font's resource fork whole, up to 256 MB.
Before this change, the search applied its limit to each font only
after that read. One search thread could read 256 MB from a single
suitcase and hold a copy of its fonts as well. The search now reads a
suitcase font only when its resource fork is 8 MB or less. The font
scan still reads each suitcase font's resource fork whole, up to
256 MB.

Docs: docs/mcp.md.
2026-10-10 08:42:32 +07:00
dotCooCoo 5709e636a5 Fonts: missing fonts are listed when a document opens, with Find Fonts and a folder search
When a document opens with fonts that aren't available, fonts used
only in its symbols included, VectorCraft lists them in a Missing
Fonts dialog after the missing linked file questions, one document at
a time, with Find Fonts and Close. Find Fonts opens Type › Find Font
on the first missing font of the layers' type, to replace it. After
Find Fonts or Close the dialog doesn't open again for that open; when
another dialog replaces it, it opens again once that dialog closes.

Search Fonts Folder (the Fonts folder File › Package writes next to
the document) and Find in Folder… search a folder and its subfolders
for the fonts' files, and Add Fonts copies the files the user chooses
into a Fonts folder next to the preferences. The desktop app and
vectorcraft-cli read that folder with the system's font folders. Type
› Find Font has a Find in Folder… button too when fonts the document
uses are missing. The web app shows no dialog; a status message says
how many fonts aren't available.

- text.missingFonts lists each missing family and style once,
  whatever version the document's type names, and leaves out CSS
  generic families such as sans-serif. text.findFontFiles searches a
  folder on up to 8 threads and returns at once; text.findFontFiles {}
  reports the search, and {stop: true} stops it. A search reads only
  what the font scan reads of font files (their table directories and
  name, fvar and OS/2 tables) and, on macOS, of suitcase fonts, and
  lists a file for a font only when the file makes the font resolve
  exactly. It does not enter app packages, the Program Files and
  ProgramData folders, the system's folders at the root of a volume,
  the Shared and Public folders in Users, a home folder's Library,
  AppData, Applications, snap and dot folders, a Library folder's app
  data folders or the folders the environment names for apps, and it
  skips other hidden folders and does not follow links below the
  picked folder. It ends after 60 s by default, after 10,000,000
  entries listed, with more than 1,000,000 folders waiting, after
  10,000 font files read or 1,000 kept, or once every font has a file;
  Stop and Close end it too.
- text.addFontFiles copies only files the last search found. Of
  those, it copies only regular .ttf, .otf, .ttc, .otc, .woff and
  .woff2 files and suitcase fonts with their resource fork, at most
  256 MB a file and 1 GB in all. It never replaces a file: a file with
  the same contents is kept, and when another file has that name, the
  copy gets a number (Name 2.otf). The fonts are then scanned again,
  and type set in them redraws.
- ui.missingFontsDialog and ui.findFontsInFolder open the dialog for
  agents; docs/control-protocol.md lists its fields.
- File › Package gives a font file whose name has no font extension
  the extension that matches its data (.ttf, .otf or .ttc).
- vectorcraft-text adds set_app_font_dir, which system_font_dirs
  reads, WantedFonts, which reads a font file's names as the font scan
  does, is_font_file and is_suitcase. #762 split its face reader, which
  now takes read budgets: file_face_names_within tries the suitcase
  reader first, and reader_face_names_within applies the budgets to
  each font. vectorcraft-format adds write_new_with.

Docs: docs/mcp.md, docs/control-protocol.md, ROADMAP.
2026-10-10 08:42:32 +07:00
BFlat 796accffc0 Merge pull request #675 from fastmikey99/fix/pdf-import-past-page-clip
PDF import: clip art reaching past the page to the crop box
2026-10-10 08:41:47 +07:00
BFlat ef574b6319 Merge pull request #854 from rschuerer/fix/ai-non-native-art
Fix #861: open .ai files whose non-native art has ASCII85 lines starting with %_
2026-10-10 08:37:32 +07:00
David Campos ec32a0b537 Add tests for SVG drop handling 2026-10-10 08:32:19 +07:00
David Campos 8b95f79f3b Handle pathless SVG drops by sniffing bytes 2026-10-10 08:32:19 +07:00
David Campos b6625f6964 Open dropped SVG paths via the shared reader 2026-10-10 08:32:19 +07:00
David Campos 8edee45588 Use File Open path for native SVG drops
Route native SVG drops through the same reader as File > Open.
2026-10-10 08:32:18 +07:00
bflatasticandClaude Opus 5.5 d38a2fa023 Review fixes for #675: ROADMAP entry for the PDF page clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:30:48 +07:00
fastmikey99andClaude Opus 5.5 c2339fca81 PDF import: leave art past the page unclipped in a PDF-compatible .ai
The PDF part of an .ai stands in for the editable document, whose art crossing
the artboard edge isn't clipped, so it keeps opening whole and editable (#646).
Plain PDFs still get the clip to the crop box. path_count is back to counting
every path: no clipping paths appear in the .ai cases it counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:26:48 +07:00
fastmikey99andClaude Opus 5.5 1d0305fc2a PDF import: clip art reaching past the page to the crop box
A clip to the page box is dropped as redundant (by hayro, and by
push_clip_path), so art a file hides outside the page - a mock-up backdrop
thousands of points across, say - came in unclipped: it showed, and it sized
Fit to Artwork Bounds and CropTo::Bounding to metres. Viewers show a page
through its crop box, so each object reaching past it now keeps its own clip
to it, with a warning. Art inside the page is unchanged (no clip groups).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:26:26 +07:00
Reinhard SchuererandClaude Fable 5.1 f2ba41d1c1 Open .ai files whose non-native art has ASCII85 lines starting with %_
A letterhead saved by Illustrator 28 without PDF compatibility wouldn't
open: "its editing data can't be read (it ends inside a layer)". Its
layer starts with a /ForeignObject (non-native art: the content of a
placed PDF), whose /Data entry is the PDF as ASCII85 in comment lines.
ASCII85 includes `_`, so about one line in 85 starts with `%_`, which
the lexer reads as hidden tokens; a `(` in such a line opened a string
that swallowed the dictionary's end, the rest of the art and the
layer's LB.

The reader now skips to the data's `~>` after `/Data ,` in a
/ForeignObject, as it already did for a /Binary dictionary's ASCII85,
and leaves the non-native art out with a note (nothing draws its PDF
from the editing data yet).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch
2026-10-10 08:25:35 +07:00
BFlat 57932f1204 Merge pull request #748 from storytold/fix/735-editing-data-drift
Fix #735: Illustrator EPS and .ai files match their page in Fit to Artwork Bounds and the page check
2026-10-10 08:02:48 +07:00
Brandon ThomasandClaude Opus 5.5 537fbece16 Bump version to 0.8.0 (from 0.7.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:29 -04:00
BFlat 866ea5878a Merge pull request #855 from dylanmtaylor/chore/update-actions
chore: bump github actions to fix Node 20 deprecation warnings
2026-10-10 07:08:13 +07:00
Dylan M. Taylor 4f0809953d chore: bump github actions to fix Node 20 deprecation warnings 2026-10-09 20:04:42 -04:00
bflatasticandClaude Opus 5.5 c599522061 Fix #735: Illustrator EPS and .ai files match their page in Fit to Artwork Bounds and the page check
- Fit to Artwork Bounds fits the art exports draw: hidden objects and
  layers, guides and template layers don't count, and the bounds stop at
  a layer's clip.
- The comparison with the page catches an object one draws that the
  other doesn't (pixels whose neighbours all differ, away from type), a
  light colour against white, and, for an EPS whose page is its art's
  box, art the layers print outside it.
- textAs: outlines opens a .ai whose type shows from its PDF part, which
  has the outlines.
- document.open takes editingData: false to open an Illustrator EPS or
  .ai as its page or PDF part only, for print pipelines.

Part of #637.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:51:50 +07:00
BFlat f203ae6334 Merge pull request #851 from rschuerer/fix/windows-display-gpu
Fix #849: draw on the GPU that drives the primary display
2026-10-10 06:41:47 +07:00
BFlat 569b4ac240 Merge pull request #853 from storytold/fix/clippy-typecmd-borrow
Fix clippy on main: Create Outlines passes the text, not a reference to it
2026-10-10 06:35:13 +07:00
bflatasticandClaude Opus 5.5 2c1a1526df Fix clippy on main: Create Outlines passes the text, not a reference to it
needless_borrow from #850, which a stale lint result let through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:27:19 +07:00
BFlat 5b27fdc321 Merge pull request #845 from dotCooCoo/feat/copy-to-user-defined
Swatches: swatch.library.copyToUser copies a loaded library into User Defined
2026-10-10 06:21:36 +07:00
BFlat 338ed529e4 Merge pull request #850 from storytold/fix/847-underline-strikethrough
Fix #847: draw Underline and Strikethrough
2026-10-10 06:17:48 +07:00
BFlat b2b3b4fe6a Merge pull request #848 from storytold/fix/hyphen-test-race
Hyphenation exceptions: unit tests read only their thread's list
2026-10-10 06:17:39 +07:00
Reinhard SchuererandClaude Fable 5.1 77a3cef4d1 Draw on the GPU that drives the primary display on Windows and Linux
On a desktop with a Ryzen integrated GPU without a monitor and an NVIDIA
card driving both monitors, Automatic (power saving on Windows) picked
the integrated GPU; Windows presented every frame across adapters and
the integrated GPU's driver reset, losing the graphics device or taking
every monitor down for minutes (same bug as pdfcraft#378).

Automatic now asks the system which GPU drives which display (Windows:
EnumDisplayDevices through winsafe; Linux: /sys/class/drm) and ranks the
GPU driving a display, and among those the primary display's, ahead of
the power preference. Where the system can't say, or no adapter's PCI
ids match (OpenGL, Metal), the order is the power preference's as
before, so nothing gets worse. Power Saving and High Performance chosen
by the user, and WGPU_POWER_PREF, keep ordering by kind alone.

The log now lists the display GPUs, which adapters drive a display, and
the adapter drawn on with the reason, the first question in every
black-window report. Tests cover the device-id parser, the rankings
(desktop with all monitors on the discrete GPU, hybrid laptop, a monitor
on each GPU, no match), the sysfs reader and a live EnumDisplayDevices
call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch
2026-10-10 01:08:33 +02:00
bflatasticandClaude Opus 5.5 72c9cec0e0 Fix #847: draw Underline and Strikethrough
The styles were stored on the text and written to SVG's text-decoration,
but the type was drawn without the lines. vectorcraft_text::decorations
now gives each underlined or struck-through stretch of a run on a line
its bar, placed and sized from the font's own underline and strikeout
metrics (with fallbacks for fonts that have none). The canvas, Create
Outlines, PDF export (outlined and real text) and SVG's outlined text
draw them in the run's paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:03:04 +07:00
bflatasticandClaude Opus 5.5 4181755db6 Hyphenation exceptions: unit tests read only their thread's list
hyphenation_exceptions_override_the_pattern failed when the round-trip
test set the process-wide list at the same moment: words missing from the
thread's override fell through to the shared store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:02:48 +07:00
BFlat c93d4ad995 Merge pull request #810 from Messhias/feat/394-hyphenation-exceptions
Part of #394: Hyphenation › Exceptions reach the hyphenator
2026-10-10 05:46:03 +07:00
dotCooCoo 975d4975ee Swatches: swatch.library.copyToUser copies a loaded library into User Defined
swatch.library.copyToUser {library} copies a library loaded from
another folder into the user library folder of the desktop app. A
.vcswatches, .gpl or .ase file is copied as it is; the swatches of a
document and a library loaded from the data or dataBase64 parameter are
written as a .vcswatches file. The copy gets a name that no file in the
folder has, and when a file with the same extension and bytes is
already there, that file is used. The library is then listed as User
Defined only. Until the app quits, commands given its loaded id use the
copy, unless another library is loaded under that id. Without a user
library folder (the web and headless sessions) the command is disabled.

A file with the same extension and bytes as a User Defined library
opens as that User Defined library. The unchanged original of a copied
.ase file is one such file. Graphic style libraries open the same way.
2026-10-09 13:32:59 -07:00
BFlat 4cf912fa94 Merge pull request #820 from storytold/fix/812-ellipse-pie
Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
2026-10-10 03:00:32 +07:00
bflatasticandClaude Opus 5.5 78f0297127 Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
An ellipse kept pie angles in its model, but nothing drew or set them. Now
the Transform and Properties panels show Ellipse Properties with Pie Start
Angle, Pie End Angle and Invert Pie, as in Illustrator, and
object.setLiveShape takes {pieStart, pieEnd, invertPie}. The pie runs
counterclockwise on screen from the start angle (0 at 3 o'clock) to the
end angle and closes through the centre; 0 to 360 is the whole ellipse,
an end of 0 means 360, and Invert Pie swaps the angles to show the other
part. One undo step each; a bad angle is an error. The labels are
translated in every complete language. The on-canvas pie widget is still
to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:00:15 +07:00
BFlat d857d2c0b7 Merge pull request #814 from jmverges/font-versions
Fix #813: type can name an installed version of its font
2026-10-10 02:44:09 +07:00
BFlat 9df597a46d Merge pull request #816 from storytold/fix/807-all-tools-drawer
Fix #807: All Tools shows every tool with its icon, under headings
2026-10-10 02:33:18 +07:00
BFlat 4c175ed099 Merge pull request #815 from storytold/fix/808-slim-toolbar-scrollbar
Fix #808: the toolbar's scroll bar stays a slim line
2026-10-10 02:33:10 +07:00
bflatasticandClaude Opus 5.5 a262ee5c9e Fix #807: All Tools shows every tool with its icon, under headings
The toolbar's All Tools drawer listed the tools' names as plain buttons.
It now shows each tool with its icon and name in a grid, under the
toolbar's group headings (Select, Shapes, Draw, Modify, Type, Navigate,
Color) and headings for the tools only the Advanced toolbar shows
(Perspective, Symbols, Graph, Artboards, Slices). The active tool is
highlighted, a tool's tooltip gives its name and shortcut, and picking one
selects it and closes the drawer. The headings use labels translated
already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:32:52 +07:00
bflatasticandClaude Opus 5.5 986b83280f Fix #808: the toolbar's scroll bar stays a slim line
The toolbar and the dock's icon column scroll when the window is too
short for them. Their scroll bar was egui's floating one, which widens to
10 pt while scrolled or dragged and covered the group labels and the
icons' right edge. It now stays at most 4 pt wide (2 pt at rest), at the
column's edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:32:27 +07:00
Fabio William ConceiçãoandCursor 445bceb681 Part of #394: look up hyphenation exceptions under the lock; drop ~ syntax.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 20:26:56 +01:00
Fabio William ConceiçãoandCursor 4f9b292213 Part of #394: headless tests for Hyphenation › Exceptions UI and layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 20:26:56 +01:00
Fabio William Conceição c9ba5db4fb Part of #394: Preferences › Hyphenation › Exceptions reach the hyphenator.
Comma-separated words never break, or break only at marked hyphens; prefs.set and restore push the list so type reflows.
2026-10-09 20:26:56 +01:00
Juan Manuel Verges SolanasandClaude Opus 5.5 562e09707a Fix #813: type can name an installed version of its font
A font library can hold two versions of one family and style (a new
Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept
only the first one loaded, so type set in the other couldn't be matched
when a PDF or .ai was opened: it came in as outlines. Now every version
loads (the first stays the one family and style resolve to), PDF import
tries the other versions when the glyphs differ, and type set in a version
the family and style alone wouldn't pick names it in CharStyle::font_version
(left out when None, so documents without it are unchanged). Layout,
shaping, the substituted-glyph highlight, Find Font, Package and Document
Info follow it; changing the font clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:26:51 +00:00
BFlat b43e633329 Merge pull request #754 from hamada147/feature/support-arabic-language
feat(text): load craft-fonts' Arabic families, fetch them on web
2026-10-10 02:08:29 +07:00
bflatastic 8c1be41e08 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	ROADMAP.md
2026-10-10 01:56:46 +07:00
BFlat cbacb47fdb Merge pull request #770 from dotCooCoo/feat/swatch-libraries
Swatches: Save Swatch Library writes swatch exchange (.ase) files
2026-10-10 01:53:32 +07:00
BFlat 116573ba86 Merge pull request #782 from dotCooCoo/feat/package-font-resolution
Package: fonts found by any of their names are copied, and skipped fonts are named
2026-10-10 01:43:01 +07:00
BFlat 783540a34a Merge pull request #781 from dotCooCoo/feat/macos-registered-fonts
Part of #579: macOS fonts registered from other folders are listed too
2026-10-10 01:42:54 +07:00
shmuelbiller1 b4784c02ee test(package): retain and report the boundary file at the nesting limit 2026-10-09 14:36:37 -04:00
shmuelbiller1 6f260fac3e fix(package): propagate optional metadata for cyclic links without nesting Options 2026-10-09 14:36:06 -04:00
shmuelbiller1 7d75280ab4 fix(package): preserve FONTS report header and document warning output 2026-10-09 14:35:42 -04:00
shmuelbiller1 c8642eeddb test(package): retain unreadable children and break circular dependencies without aborting 2026-10-09 14:34:21 -04:00
shmuelbiller1 0b326c48bc fix(package): keep copying linked documents on parse failures, cycles and depth limits 2026-10-09 14:33:39 -04:00
BFlat 35a6015d88 Merge pull request #803 from haranishi/i18n-japanese-messages
i18n: Japanese translations for error and status-bar messages
2026-10-10 01:15:02 +07:00
BFlat 71ea5c4215 Merge pull request #802 from dmatviichuk/i18n/ukrainian-dictionary-review
i18n: correct Ukrainian wording and spelling
2026-10-10 01:14:55 +07:00
BFlat 3e1d9b7bbb Merge pull request #795 from DWAA1660/fix/selection-validation-and-clear
Validate selection IDs and clear exact compound members
2026-10-10 01:05:58 +07:00
Ikki NishiharaandClaude Sonnet 5.5 cee9feea2e i18n: Japanese translations for error and status-bar messages
Japanese now translates the status-bar and error messages (`@msg`
templates) like Spanish, French, Italian, Russian and Ukrainian do:
556 rows at the end of ja.tsv, the 498 message literals the test scan
finds plus the 58 that the other catalogs also carry (web start-up,
relinking, export notes, PostScript read errors).

`ja` joins COMPLETE_MESSAGES, so complete_languages_translate_every_message
keeps it complete. The registry comment, docs/development.md and the
ROADMAP line now list Japanese with the other five. The messages
themselves stay English for agents and MCP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 02:53:14 +09:00
BFlat d522c1d7be Merge pull request #800 from storytold/fix/793-artboard-options-and-layouts
Part of #793: Artboard Options lists Name, Width, Height; one-key shortcuts follow the key on any layout
2026-10-10 00:52:17 +07:00
Dmytro MatviichukandCodex 01f329aa99 i18n: correct Ukrainian wording and spelling (#747)
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 19:51:43 +02:00
bflatasticandClaude Opus 5.5 f300321e28 Part of #793: Artboard Options lists Name, Width, Height; one-key shortcuts follow the key on any layout
- Artboard Options listed its fields by key (Height, Name, Width); it now
  lists Name, then Width and Height.
- One-key tool and command shortcuts (V, P, X, D…) matched the character
  typed, so with a layout without Latin letters (Persian, Arabic, Russian,
  Greek…) they did nothing. When the text typed holds no ASCII character,
  the key's position stands in: the V key selects the Selection tool
  whatever it types. Latin layouts (AZERTY…) keep the letter typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:51:18 +07:00
BFlat 0b14b45610 Merge pull request #796 from storytold/fix/flaky-native-menu-test
Native menu test: judge idle frames while the global generations hold still
2026-10-10 00:36:35 +07:00
bflatasticandClaude Opus 5.5 0928cafab2 Native menu test: judge idle frames while the global generations hold still
key_equivalents_run_through_the_normal_shortcut_path_once failed about one
full ui-egui run in four ("the menus weren't read again", 5 vs 4). Each
app mirrors its shortcut overrides into a process-wide store every frame,
bumping shortcut_editor::GENERATION, and the plug-in registry's revision is
process-wide too. Tests running alongside with other shortcuts flip them,
which rightly makes the menus read again. The test now checks two idle
frames during which those generations held still (trying up to 50 times),
so it still fails if idle frames re-read the menus on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:36:14 +07:00
DWAA1660 636d9b24c8 Fix #786: clear exact compound members and explicit targets 2026-10-09 11:33:23 -06:00
DWAA1660 04439bb01d Fix #784: validate selection IDs and report the resulting selection 2026-10-09 11:19:08 -06:00
BFlat 21355945ea Merge pull request #794 from storytold/fix/776-pen-joins-paths
Fix #776: the Pen continues any open path and joins two paths
2026-10-10 00:01:01 +07:00
bflatasticandClaude Opus 5.5 bd8fa795ed Fix #776: the Pen continues any open path and joins two paths
As in Illustrator, the Pen shows its continue cursor over an end of any
open path, selected or not, and a click there selects the path and draws
on from that end (reversing it from its first end). While drawing, it
shows a join cursor over an end of another open path, and a click joins
the two into one path, in one undo step, which finishes the path.

path.join takes {ids: [a, b], ends: ["first"|"last", "first"|"last"]}
to join the ends asked for (the Pen's join); without them it joins the
selected paths' nearest ends as before. Joining a path with itself, or
anything that isn't a path, is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:59:50 +07:00
BFlat 825d4fdb8b Merge pull request #791 from storytold/fix/734-crop-image-box
Fix #734: Crop Image shows a crop box on the image
2026-10-09 23:40:58 +07:00
bflatasticandClaude Opus 5.5 4b44265965 Fix #734: Crop Image shows a crop box on the image
Object › Crop Image and the image's Crop Image buttons (Control bar,
Properties) now show a crop box on the selected image, as Illustrator
does, instead of cropping straight to the artboard (which did nothing for
an image inside its artboard). The box starts on the image's part over its
artboard; its handles and its inside drag it, always within the image, and
the image outside it is dimmed. The Control bar shows its X/Y/W/H with
Apply and Cancel; Enter applies and Escape cancels, both back to the
Selection tool. Apply runs object.cropImage {rect}, one undo step.

- tools: the Crop Image tool (cropimage.rs), with crop_box shared by the
  tool and the Control bar.
- ui: ui.cropImage (enabled with an image selected), the Control bar's
  crop fields and buttons, "Apply" translated (es, fr, it, ru, uk, ja,
  zh-hans, zh-hant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:40:12 +07:00
bflatasticandClaude Opus 5.5 9de418a4a4 ROADMAP: the Arabic families come with a craft-fonts checkout that has them
craft-fonts main holds only Noto Sans Arabic so far (the other eleven are in
storytold/craft-fonts#11), and releases pin an older craft-fonts commit, so
say which builds list them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:16:41 +07:00
BFlat 8e0e2eb904 Merge pull request #790 from storytold/fix/clippy-199-affinity-tests
Clippy 1.99: as_chunks in the Affinity raster tests
2026-10-09 23:15:59 +07:00
bflatasticandClaude Opus 5.5 8124cf7b43 Clippy 1.99: as_chunks in the Affinity raster tests
#763 landed beside #771 with five chunks_exact(4) calls in
crates/affinity/tests/raster.rs, which clippy 1.99's chunks_exact_to_as_chunks
rejects, so cargo xtask ci failed at clippy on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:15:36 +07:00
shmuelbiller1 f9384dfed7 test(package): nested native files retain PDF compatibility preview and compression 2026-10-09 11:55:49 -04:00
shmuelbiller1 71484fd013 fix(package): preserve nested previews, PDF content, compression and embedded assets 2026-10-09 11:54:37 -04:00
shmuelbiller1 7c3687af29 test(package): distinct nested assets sharing a stale source path remain separate 2026-10-09 11:52:37 -04:00
shmuelbiller1 ba1b7a834f fix(package): deduplicate by resolved source and rebase links per containing document 2026-10-09 11:51:54 -04:00
shmuelbiller1 bbb465a0d3 style(doc): format portable link traversal chain 2026-10-09 11:39:15 -04:00
shmuelbiller1 f549f6ad4d style: keep new package traversal within rustfmt width 2026-10-09 11:37:28 -04:00
shmuelbiller1 a9e3d85b1d style: keep new package traversal within rustfmt width 2026-10-09 11:37:24 -04:00
shmuelbiller1 a4a040653d test(package): resolve nested links from their actual source after moving files 2026-10-09 11:35:51 -04:00
dotCooCoo 75f2145b92 Package: fonts found by any of their names are copied, and skipped fonts are named
File › Package and Document Info › Font Details looked fonts up with
FontDb::face and compared the face's family with the name the document
stores. A font a document named by another of its names (a PostScript
or localized name, as SVG and PDF files do) was reported as not
available and left out of the package, although the canvas drew it and
exports embedded it.

- Package and Font Details resolve fonts with FontDb::resolve, as
  text.fonts and the renderer do. A style the family lacks is still
  copied as its closest style; the report now names the style it
  copies, and Font Details lists the font as substituted, with that
  style's file and permission.
- FontFace::embeddable reads fsType as exports do (Embedding), so fonts
  that allow bitmap embedding only (bit 9), or that set the restricted
  bit together with reserved bit 0 (0x0003), are no longer copied.
  Document Info's permission labels follow the same rule: when a font
  sets more than one usage bit, the label names the most permissive
  one.
- The Package dialog and its status line name the fonts it didn't copy.
2026-10-09 08:35:16 -07:00
shmuelbiller1 deff7bb757 fix(package): resolve transitive links against the moved file's actual location 2026-10-09 11:34:02 -04:00
shmuelbiller1 022069d5a5 fix(links): retain resolved source paths when packaging moved nested documents 2026-10-09 11:33:42 -04:00
shmuelbiller1 1f668dfaad fix(package): retain nested documents' embedded ICC profile bytes 2026-10-09 11:32:32 -04:00
BFlat d168d4f843 Merge pull request #771 from DisasterAreaDesigns/affinity-as-chunks
Clippy 1.99: use as_chunks for constant-size chunks
2026-10-09 22:32:02 +07:00
shmuelbiller1 816a0fe762 test(package): reopen nested linked documents from portable folder and ZIP deliveries 2026-10-09 11:31:30 -04:00
shmuelbiller1 4fe06391d4 fix(package): retain existing top-level missing-link reporting API 2026-10-09 11:30:54 -04:00
shmuelbiller1 ae68704dfc fix(package): preserve rebased portable paths for relative output folders 2026-10-09 11:29:53 -04:00
shmuelbiller1 eacef005b0 fix(package): recursively collect and rebase placed documents and their assets 2026-10-09 11:29:01 -04:00
shmuelbiller1 c461369421 feat(doc): allow portable relinking of images and placed documents 2026-10-09 11:27:13 -04:00
BFlat 9ff0277306 Merge pull request #763 from callmeBalloch/feat/affinity-af-validation
AF.4: harden current .af artboards, text and images with a pinned corpus
2026-10-09 22:21:20 +07:00
bflatasticandClaude Opus 5.5 573f27e6c9 AF.4 docs: leave out shop listings and sandbox notes
The source audit linked commercial template listings and other pages it
didn't use, and described the environment it ran in. Keep the two sources
the corpus uses, say in a sentence what kinds of sources were left out and
why, and state plainly that reopening our output in Affinity is still to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:20:39 +07:00
dotCooCoo 4b00c2d968 Part of #579: macOS fonts registered from other folders are listed too
The font scan read only the macOS font folders and the Additional
Fonts Folder, so fonts that apps and font managers register with
CoreText from their own folders were missing from the font menus, and
exports set such type in the fallback font. CoreText's font manager
lists them with the installed fonts.

- `mac_fonts.rs` lists the files of the fonts the font manager has
  available (`CTFontManagerCopyAvailableFontURLs`, through
  objc2-core-text), keeping only file URLs as `open_documents.rs` does
  (#433). The desktop app and `vectorcraft-cli` install it as they
  install the DirectWrite lister on Windows, and the scan reads the
  files outside the font folders in place.
- The scan leaves out faces with no outlines the font engine draws (no
  glyf, CFF, CFF2 or VARC table), on every platform. CoreText lists
  Apple's PingFang UI interface font (macOS 27), whose outlines are in
  an hvgl table; if the scan cataloged it, PingFang SC would count as
  installed, and Chinese characters in fallback text, Japanese kanji
  included, would draw blank.
- Tests: the list holds the installed fonts by full path, a font
  registered from another folder is listed while it is registered, and
  a face without outlines the engine draws isn't cataloged.
2026-10-09 08:16:33 -07:00
dotCooCoo a61d058f1b Swatches: Save Swatch Library writes swatch exchange (.ase) files
The Save Swatch Library dialog lists Swatch Exchange (.ase) as a
format, and swatch.library.save takes format: "ase" or a path ending
in .ase. Each solid color keeps its model (RGB, CMYK, Lab or Gray) and
its type (spot, global or process), and color groups stay groups.
Gradients and patterns are left out, and a tint swatch is written as
the color it shows. Without a path or user: true, the command returns
the file as dataBase64.

A swatch exchange library opens under the name of its file. When Save
Swatch Library or Save Graphic Style Library saves to a file, the save
panel suggests the Name from the dialog as the file name, as a save to
User Defined does, and the web app downloads the file under that name.
A name made only of dots and spaces, such as "...", gives the file name
Library (Library.vcswatches) in place of a hidden file.

palette_io::write returns the file's bytes. A property test writes
generated libraries and reads them back, and an import fuzz test loads
mutated copies of a file the writer wrote.
2026-10-09 08:15:09 -07:00
BFlat 0a91fd9b44 Merge pull request #762 from DisasterAreaDesigns/suitcase-fonts
Fonts: find suitcase fonts, whose font data is in the resource fork
2026-10-09 21:49:07 +07:00
Matthew FarrowandClaude Sonnet 5.5 75151bd79a Clippy 1.99: use as_chunks for constant-size chunks, and say the gate targets the latest stable
Clippy 1.99.0 added `chunks_exact_to_as_chunks`, which fails `cargo xtask ci`
at its clippy step. Eight uses of `chunks_exact(_mut)` with a constant size now
use `as_chunks(_mut)` (stable since 1.88, within the 1.95 minimum): four in
crates/affinity, three in crates/effects and crates/engine tests, and two in
xtask's sha256. docs/development.md says the gate targets the current stable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:43:23 -04:00
bflatasticandClaude Opus 5.5 be9a879e51 Fonts: cap the total a suitcase fork can make the scan copy; no fork look-up off macOS
References can share data, so a damaged fork could make the scan copy one
large resource up to 256 times: cap the total copied like the fork itself.
Off macOS no file has a fork, so the scan doesn't look at each font file twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:40:42 +07:00
BFlat 16dcbc6138 Merge pull request #769 from storytold/fix/740-move-snaps-to-grid
Fix #740: moved art snaps to the grid
2026-10-09 21:37:23 +07:00
bflatasticandClaude Opus 5.5 7dd69aaf81 Fix #740: moved art snaps to the grid
With View › Snap to Grid on, a selection moved with the Selection, Group
Selection or Direct Selection tool lands on the grid: on each axis,
whichever of its two edges and its centre is nearest a gridline (every
subdivision, 9 pt by default) moves onto it. Snap to Grid takes over from
Smart Guides and Snap to Point while it is on, as it does for drawn points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:36:42 +07:00
Matthew FarrowandClaude Sonnet 5.5 d35db792dc PDF tests: the system-fonts test checks the first installed family that stays live text
The test took the first installed family that isn't bundled and expected its
PDF text to stay live. The PDF doesn't embed the font, so the page draws a
stand-in and text stays live only where the installed font's glyphs match it.
With suitcase fonts now found, the first family on a Mac is a condensed
display face whose glyphs don't, so the importer rightly kept outlines and the
test failed. Which family comes first depends on the machine's fonts: the test
now tries up to 200 families and checks the first that stays live.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:29:11 -04:00
Ahmed Moussa 806ba127e7 Merge branch 'main' into feature/support-arabic-language 2026-10-09 17:19:05 +03:00
Ahmed Moussa 3e20c2cf29 build(web): copy web fonts with cargo xtask, not sh
Replace the Trunk post_build hook packaging/web/copy-fonts.sh with `cargo xtask web-fonts` (xtask/src/web_fonts.rs), so the web build only needs trunk, the wasm32 target and cargo, with no POSIX shell, and works the same on Windows.

The new task reuses crates/text/build/web_fonts.rs for list parsing and URLs, so build.rs and the copier can't disagree on fonts/<sha16>/<file>. It still does nothing without CRAFT_FONTS_DIR, checks each file's SHA-256 against the manifest, and copies the licence next to each face. A listed face that an older craft-fonts checkout lacks is now skipped with a warning, as build.rs does, instead of failing the build. A relative CRAFT_FONTS_DIR is resolved from the workspace root.

Update the references in Trunk.toml, fonts.rs, build.rs, craft_fonts.rs and web-fonts.txt, and document the web build's requirements in docs/development.md. Add tests for the copy, a hash mismatch and the no-op case.

Signed-off-by: Ahmed Moussa <moussa.ahmed95@gmail.com>
2026-10-09 17:18:54 +03:00
Matthew FarrowandClaude Sonnet 5.5 e1d35131d3 Fonts: find suitcase fonts, whose font data is in the resource fork
macOS lists fonts such as the ones Microsoft Office installs in
/Library/Fonts/Microsoft (Bookman Old Style, Impact, Georgia, Tahoma, ...) but
their files have nothing in the data fork and no font extension: the fonts are
`sfnt` resources in the resource fork. The font scan only read data forks of
.ttf/.otf/.ttc/.otc files, so those families were never cataloged and a file
using one (an EPS naming BookmanOldStyle-Bold) showed the font as missing.

The scan now catalogs a file with an empty data fork and a resource fork by
the fonts in that fork, and loading reads them the same way. The fork reader
(crates/text/src/suitcase.rs) follows Apple's published resource file format,
caps what a damaged fork can make it read and never panics. macOS only.

Tests: the fork reader on a synthetic fork built around a bundled font,
including truncated and garbage forks; a macOS scan of a suitcase file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:05:18 -04:00
BFlat aa548d2bef Merge pull request #760 from storytold/feat/windows-setup-wizard
Packaging: Windows MSI setup wizard (folder choice, progress, version, launch on finish)
2026-10-09 20:56:48 +07:00
Riccardo Balloch 79c5d673ff AF.4: validate and harden Affinity 3 artboards, text and images 2026-10-09 13:45:55 +00:00
BFlat f837c9305a Merge pull request #759 from jmverges/ai-import-draw-page-once
Part of #758: .ai import draws the page once when weighing the layers
2026-10-09 20:44:56 +07:00
BFlatandClaude Opus 5.5 f95e320907 Packaging: Windows MSI gets a setup wizard with version and launch box
Double-clicking the MSI used to flash a progress bar and close, so users couldn't tell it had
installed. It now opens a wizard: Welcome, install folder (remembered across upgrades), Ready,
progress, and a Finish page that says where to find the app with a ticked "Launch VectorCraft"
box. Every page names the full version, pre-release tag included. The wizard art is drawn from
the app icon at package time; msiexec /qn still installs silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:41:53 +07:00
Juan Manuel Verges SolanasandClaude Opus 5.5 1c692e5191 Part of #758: .ai import draws the page once when weighing the layers against it
Each comparison of the layers' art with the page made a new renderer and
drew both again, so the page was drawn and every image decoded once per
text object weighed, plus once more for the final check. The page is now
drawn once, and the layers keep one renderer (with its decoded images)
across their draws. The page's images keep a renderer of their own, as
their keys can match the layers' without their pixels doing so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:34:06 +00:00
BFlat a89426c2f6 Merge pull request #756 from storytold/feat/745-libraries
Fix #745: a working Libraries panel of graphics, colours and text styles
2026-10-09 20:24:33 +07:00
bflatasticandClaude Opus 5.5 7255ac394d Fix #745: a working Libraries panel of graphics, colours and text styles
The Libraries panel was a placeholder: it invited dragging art onto it,
but nothing happened. It now keeps local libraries (no account, no
cloud), as Illustrator's Libraries panel does:

- drag art off the canvas (or a swatch) onto the panel to add it, or use
  + to add the selection's graphic, fill or stroke colour, character
  style or paragraph style;
- drag a graphic onto the canvas (or double-click it) to place a copy,
  click a colour to paint the selection through the active Fill/Stroke
  proxy, click a style to apply it to the selected type;
- the library picker and the panel menu create, rename and delete
  libraries; each is a .vclibrary file in a Libraries folder next to the
  preferences (session-only in the web app, headless sessions and runs
  without preferences).

Engine: library.list/get/setCurrent/create/rename/delete/add/use/
removeItem. A graphic is stored as a native document with a thumbnail:
adding copies as Copy does and placing pastes as Paste does, resources
and swatch-name conflicts included. Using a style adds it to the
document and applies it in one undo step.

Clipboard::to_document gave its layer the id u64::MAX, which made a
saved copy overflow Document::fix_next_id when read back; the layer now
takes an id above the objects', and fix_next_id saturates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:12:59 +07:00
Ahmed Moussa 3c85f3f9e3 feat(text): load craft-fonts' Arabic families, fetch them on web
Desktop builds made with CRAFT_FONTS_DIR now load craft-fonts' 12 Arabic families into the document font database. Noto Sans Arabic comes first and is the fallback for Arabic text. Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar can be picked by name from the font menus.

The web build doesn't embed these fonts because they would make the wasm too large. It fetches them from beside the wasm instead:
- crates/text/web-fonts.txt lists each face as startup or background.
- build.rs turns that list into vectorcraft_text::WEB_FONTS, with each face's URL (fonts/<sha16>/<file>) and SRI hash taken from the craft-fonts manifest.
- packaging/web/copy-fonts.sh, a Trunk post_build hook, copies the font files and their OFL licences there.
- apps/vectorcraft-web/src/fonts.rs fetches Noto Sans Arabic before the app starts, so Arabic text never shows as boxes. It loads the other faces in the background and logs `web font …` for any that fail.

Because fonts can now arrive after startup, the Type tool's layout cache also checks the font database's generation, so text lays out again when new fonts arrive.

_headers and .htaccess serve fonts/* as font/ttf and cache it as immutable. packaging/web/README.md and docs/development.md explain the fonts folder, and ROADMAP.md lists the Arabic typefaces under Type. Also adds Ahmed Moussa to contributors/people.toml.

Signed-off-by: Ahmed Moussa <moussa.ahmed95@gmail.com>
2026-10-09 16:09:34 +03:00
BFlat c2711df30b Merge pull request #751 from dmatviichuk/i18n-ukrainian
Add Ukrainian (uk) interface translation
2026-10-09 19:29:17 +07:00
bflatasticandClaude Opus 5.5 f86618777e i18n: Ukrainian for the tab bar's » tooltip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:19:03 +07:00
bflatastic 14d8efcabf Merge branch 'pr751' into land/751 2026-10-09 19:17:34 +07:00
Dmytro Matviichuk 8a19490ea0 i18n: add Ukrainian interface translation (#747) 2026-10-09 14:15:12 +02:00
BFlat 0e6ca8f88d Merge pull request #749 from storytold/fix/746-tab-overflow
Fix #746: document tabs that don't fit are reached from a » list
2026-10-09 19:10:56 +07:00
bflatasticandClaude Opus 5.5 e8255f2bd5 Fix #746: document tabs that don't fit are reached from a » list
With more documents open than the tab strip holds, the tabs past its
right edge were cut off with no way to reach them. As in Illustrator, a
» button at the strip's right end now lists every open document (the
active one checked) and picks one, and the strip always shows the active
tab, starting later when it has to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:06:40 +07:00
BFlat dd1a32bb8e Merge pull request #744 from jmverges/fix-guides-export
Fix #743: raster export leaves guides out
2026-10-09 18:51:21 +07:00
BFlat 257c8ec435 Merge pull request #742 from jmverges/pdf-import-slanted-type
PDF import: slanted type keeps its slant
2026-10-09 18:40:19 +07:00
Juan Manuel Verges SolanasandClaude Opus 5.5 ad8458832d Fix #743: raster export leaves guides out
skip_templates, which exports and thumbnails set, left template layers out
but drew guide paths as hairlines. It now skips guide paths too, as the
SVG, PSD and PDF exports and printing already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:31:29 +00:00
BFlat a01b0dfc7a Merge pull request #739 from AutoHenry/feat/602-scale-art-with-artboard
Fix #602: Scale Artwork with Artboard
2026-10-09 18:29:28 +07:00
Juan Manuel Verges SolanasandClaude Opus 5.5 ddd1c8fade PDF import: slanted type keeps its slant
A text matrix that shears the glyphs (type turned and slanted as a whole)
came in as point type turned by the baseline's angle only: the glyphs stood
square on the baseline, so the type drew somewhere else (60 pt type leaning
by 0.25 came back about 10 pt off its outlines). The placement now keeps the
slant (the em's lean along the baseline per unit of its height, the height
measured across the baseline), lines only join glyphs with the same slant,
and the type object's transform shears by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:18:03 +00:00
BFlat eefe849e14 Merge pull request #741 from storytold/fix/733-pdf-live-text
Fix #733: PDF export keeps type as text in embedded subset fonts
2026-10-09 18:16:34 +07:00
bflatasticandClaude Opus 5.5 00344f9b68 Fix #733: PDF export keeps type as text in embedded subset fonts
The PDF writer defaulted to Convert Text to Outlines, so every glyph of
every page became a path: a text-heavy PDF grew several times larger on
export (a 7-page document went from 27 KB to 226 KB) and its text could
no longer be selected or searched. Illustrator's PDF presets embed font
subsets and keep live text; that is now the default too
(advanced.outlineText: false). Outlining stays available as an option,
fonts that can't be embedded still become outlines with a warning, and
printer's marks (page information) are drawn as shapes as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:06:38 +07:00
Hal GoffandClaude Opus 5.5 b8a61e90ce Add my contributor name (AutoHenry)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:50:35 +01:00
HTandClaude Opus 5.5 f2b35fc039 Fix #602: Scale Artwork with Artboard scales the art with a resized artboard
`artboard.setProps {scaleArt: true}` maps the art fully inside a resized
artboard (as Move Artwork with Artboard gathers it) and its artboard guides
from the old rectangle onto the new one, each side by its own ratio, in one
undo step, and returns the ids scaled. Strokes, effects and corners follow
Scale Strokes & Effects and Scale Corners (`strokes`, `corners`), pattern tiles
Transform Pattern Tiles (`patterns`); locked and hidden art follows
`lockedAndHidden` (default: prefs moveLockedWithArtboard). The resolved choices
are journaled, so a replay scales the same objects the same way. A move that
keeps the size scales nothing.

The Artboard tool's `scaleArt` option (saved with `moveArt`) makes handle
drags proportional, as the public help describes, keeps them proportional at
the 1 pt minimum, and leaves the scaled art out of the Smart Guide targets.
Move and Scale Artwork with Artboard are check boxes in the Control bar and
Properties' Edit Artboards; the artboard size fields there and in the
Transform panel pass the option. Translated in the eight catalogs that have
Move Artwork with Artboard. docs/mcp.md and the ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:50:35 +01:00
BFlat 133ad87541 Merge pull request #737 from storytold/fix/712-hide-box-dragging
Fix #712: the bounding box hides while a drag moves, scales or rotates
2026-10-09 17:46:53 +07:00
bflatasticandClaude Opus 5.5 f8a7695c3a Fix #712: the bounding box hides while a drag moves, scales or rotates
During a Selection-tool drag the bounding box and its handles stayed on
screen while the art moved, so the content looked as if it slid inside
a box of its own. The box now hides once a drag moves, scales or
rotates the selection, as in Illustrator, and comes back on release. A
press that hasn't moved, and a marquee, keep it. Tools gain
`transforming()` (Session::tool_transforming) for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:39:12 +07:00
BFlat 66cb7848f3 Merge pull request #736 from storytold/feat/724-bold-italic
Fix #724: Type › Bold and Italic, Cmd+Shift+B / Cmd+Shift+I while typing
2026-10-09 17:26:35 +07:00
bflatasticandClaude Opus 5.5 ee16d10fd4 Fix #724: Type > Bold and Italic, Cmd+Shift+B / Cmd+Shift+I while typing
Designers coming from page layout apps expect Cmd+Shift+B to bold the
text they are editing. Type > Bold and Type > Italic switch the selected
type (or the Type tool's selection) to its family's own Bold or Italic
face, and back to the regular weight or upright face when it already is,
keeping the face's other words (a Narrow face stays Narrow). There is no
fake bold or slant: a family without the face is left as it is and the
status bar says so. While the Type tool edits text, Cmd+Shift+B and
Cmd+Shift+I apply them; elsewhere Cmd+Shift+B still hides the bounding
box. Both can be given other shortcuts in Edit > Keyboard Shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:20:58 +07:00
BFlat 82af7a3108 Merge pull request #730 from storytold/feat/637-ai-without-pdf
Part of #637: .ai files saved without PDF compatibility open from their editing data
2026-10-09 16:50:03 +07:00
BFlat 46e4262376 Merge pull request #731 from storytold/fix/729-tabs-above-text
Fix #729: the Tabs panel's Above Text puts its ruler over the text
2026-10-09 16:41:55 +07:00
bflatasticandClaude Opus 5.5 c16804237e Fix #729: the Tabs panel's Above Text puts its ruler over the text
The Tabs panel's ruler was a fixed strip in the panel, at no relation to
the text on the canvas, so stops couldn't be placed by eye. Above Text
(Position Panel Above Text) floats the panel just above the selected
text, as wide as its ruler needs to put 0 on the text's left edge and
its marks at the canvas zoom: each stop sits over the column it sets.
Floating panel groups keep a width they are given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:38:49 +07:00
bflatasticandClaude Opus 5.5 4b9f9b9bc2 Part of #637: .ai files saved without PDF compatibility open from their editing data
Such a file's PDF part is a placeholder page, which was refused. When
the file carries editing data, it now opens from that alone: its
structure as a PDF-compatible file's, its point type made from the text
document, and type it can't make (area type, type on a path) left out
with a note that marks the import as lossy. Without editing data, or
with pages picked or layers off, the placeholder is refused as before;
damaged editing data says so.

The page-order note is no longer given when the page has no type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:35:22 +07:00
BFlat 008b5e6d6c Merge pull request #727 from storytold/feat/637-structure-reader
Part of #637: Illustrator editing data read whole: colours, gradients, transparency, names, images, artboards
2026-10-09 16:26:57 +07:00
BFlat 640a46a76f Merge pull request #728 from storytold/fix/725-marquee
Fix #725: the selection marquee shows on the pasteboard too
2026-10-09 16:19:10 +07:00
bflatasticandClaude Opus 5.5 9b4166ed5e Part of #637: the editing data's readers stay linear on hostile input
Zstandard frames: the trailing zero padding is trimmed once, not looked
for again before each frame, and at most 4096 frames are read (more is
damaged data). A `]` finds its `[` from a list of where they are rather
than by scanning the operand stack back. Images are read up to 32
million pixels. Tests: thousands of tiny frames, and hundreds of
thousands of unmatched brackets, read in one pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:18:23 +07:00
bflatasticandClaude Opus 5.5 2d12b39261 Palette test: look twice until shortcuts and plug-ins stay put
The cached entries' key includes two global counters that tests running
alongside bump (shortcut edits, plug-in installs), so the 'kept while the
language stays' check failed now and then. It now compares two lookups
only when the key didn't change between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:15:09 +07:00
bflatasticandClaude Opus 5.5 d53074487c Fix #725: the selection marquee shows on the pasteboard too
It was grey dashes only, which vanish on the grey pasteboard. It is now
dark dashes over a light line, so it reads on white artboards and on the
pasteboard alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:06:40 +07:00
bflatasticandClaude Opus 5.5 0be7640088 Part of #637: the editing data's structure read whole, colours, gradients, transparency, names, images and artboards included
The layers reader of the editing copy (operators translated into
PostScript for the interpreter, any file with RGB colours, gradients,
transparency or images falling back to its page) is replaced by a reader
of the whole structure (import/ai): layers and sublayers with their
options, groups, compound paths, clipping groups and layer clipping,
names, hidden and locked objects, RGB, CMYK, grey, named and spot
colours, gradients, transparency, fill rule, overprint, embedded images
with their alpha channel, guides and every artboard, and objects drawn
as their look when they have several fills or strokes, effects or a
brush. Containers: Zstandard, zlib or plain data in EPS and .ai.

The text placement, the comparison with the page and the overwrite guard
stay around it. The page's type takes its text object's place without a
group; the comparison is by lightness, on nothing, without layers that
don't print. Symbols, pattern fills, placed files and unknown operators
make the file come in as its page where they show, and are left out of
layers that don't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:02:36 +07:00
BFlat 6e2cd95532 Merge pull request #723 from imachus/pr/ja-paragraph-start-bracket
Type: an opening bracket at a paragraph's start is set flush too, JIS X 4051's principle (#633)
2026-10-09 15:52:28 +07:00
bflatastic 4f515d1b21 Merge remote-tracking branch 'origin/main' into land/723
# Conflicts:
#	ROADMAP.md
2026-10-09 15:52:10 +07:00
BFlat 9f7526aee6 Merge pull request #721 from imachus/pr/ja-kinsoku-set
Type: Kinsoku Set — Hard (today's set), Soft and None (#633)
2026-10-09 15:51:18 +07:00
BFlat 5e0efa7b1e Merge pull request #719 from jax-spenser/feat/image-trace-flat-logo-mode
Image Trace: Flat Logo mode (sub-pixel edges, restored corners, true lines and circles)
2026-10-09 15:43:17 +07:00
imachusandClaude Opus 5.5 aeaa2be10b Type: an opening bracket at a paragraph's start is set flush too, JIS X 4051's principle (#633)
With Line-end Punctuation Half Width, an opening bracket that starts a paragraph now drops the
half-em space before it, as one starting a wrapped line already does (#417): its face starts at
the first-line indent, JLREQ 3.1.5's Figure 71 ① (the principle JIS X 4051 adopts), as decided in
#633. Paragraph-start brackets in documents set with this mojikumi move half an em toward the line
start.

Two mojikumi tests used a bracket at a paragraph's start to test something else (consecutive
brackets, the Japanese–Latin space); they now set it inside the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:30 +09:00
BFlat 0aac6f1d75 Merge pull request #720 from callmeBalloch/claude/kind-darwin-btjse2
Affinity README: say which sample set the semantics were fitted on
2026-10-09 15:32:48 +07:00
bflatastic 5db4d9b78b Merge remote-tracking branch 'origin/main' into land/719 2026-10-09 15:32:23 +07:00
bflatasticandClaude Opus 5.5 ffc0205613 i18n: Flat Logo in Japanese and Chinese
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:31:24 +07:00
imachusandClaude Opus 5.5 806e543fd1 Type: Kinsoku Set — Hard (today's set), Soft and None (#633)
Paragraph panel › Kinsoku Set (with the East Asian options) and `text.setFormat {kinsoku}`, as
decided in #633:

- Hard: today's set, so existing documents don't change; the default, and not saved.
- Soft: as Hard, except that 々, the prolonged sound mark ー (JLREQ cl-10) and small kana (cl-11)
  may start a line: JLREQ's level 3 line-breaking rules (Appendix C.3). Half-width forms aren't in
  those classes and stay off the line start.
- None: no kinsoku.

The setting reaches every break the composers consider (greedy, every-line and hyphenation
points). Reset Panel returns to Hard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:26:37 +09:00
BFlat be47c41b47 Merge pull request #715 from DisasterAreaDesigns/eps-editing-data-pr
Read Illustrator EPS and .ai layers from their editing data
2026-10-09 15:25:59 +07:00
bflatastic 58b95b51ef Merge branch 'pr719' into land/719 2026-10-09 15:19:04 +07:00
bflatasticandClaude Opus 5.5 26b6893064 EPS import: clippy's simpler form of the off-page test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:06:39 +07:00
bflatasticandClaude Opus 5.5 0559aac53e Save and Export ask before replacing a file whose import left things out
The engine refuses to write over the file a document was read from when
reading it left something out, unless acknowledgeLoss is passed. In the
app that surfaced as an error naming a parameter. File > Save, Save As,
Save a Copy, Save as Template and Export As now ask instead ("Replace
'a.eps', the file this document was opened from?", with what would be
lost), and OK repeats the command with acknowledgeLoss. Closing a
document whose save asks keeps it open, and so does Package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:05:52 +07:00
bflatasticandClaude Opus 5.5 5f4f82d885 i18n: French says calque for a layer in the new import messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:05:11 +07:00
bflatasticandClaude Opus 5.5 5c040e5095 EPS import: cap the Zstandard window a hostile frame can ask for
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:05:11 +07:00
callmeBallochandClaude Opus 5.5 cdea4ff593 AF.3: say which sample set the Affinity semantics were fitted on
The README said 176 public documents in its provenance and 189 in its
limits. The thumbnail fitting was done on 176; the collection has since
grown to 189 distinct documents, and the reader opens all of them (298
files with duplicates, no failures). State both. PhotoCraft's copy of
the reader got the same fix in storytold/photocraft#1607.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 08:02:33 +00:00
bflatastic 53c4c6eb02 Merge branch 'pr715' into land/715 2026-10-09 14:57:46 +07:00
BFlat 24c77e9ddf Merge pull request #713 from storybored379/feat/mcp-conventions
MCP: common command tools, strict arguments and session resources
2026-10-09 14:56:41 +07:00
BFlat e4a7c64ccb Merge pull request #714 from storytold/fix/546-dxf-fill-outlines
Fix #546: DXF export writes each fill's outlines before its hatch
2026-10-09 14:54:06 +07:00
Matthew FarrowandClaude Sonnet 5.5 8fe0fd8394 EPS import: bound what hostile editing data can cost, and say when it can't be read
- a story is read once however many text objects name it; the text objects, their pairs with the
  page's type, the pieces of a line and a story's size are capped, and over a cap the layers aren't used
- the ASCII85 of the text document is bounded before it is decoded
- joining the pieces of lines that interleave no longer removes the wrong pieces
- a run length too large for the text saturates instead of overflowing
- a dictionary left open at the end of a layer, and layers nested deeper than are read, make the
  file one that comes in as its page (they were silently dropped)
- a file whose editing data can't be decoded says so, and the notes about art that was too much or
  too far away count as losses, so writing over the source asks first
- the private data of a .ai is inflated under a cap of its own (the PDF reader's decoder has none)
- the other writers (PDF, selection, office, print) ask before replacing a source that lost things,
  and a recovered copy remembers that

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 e622b87ca2 EPS import: don't claim a specification as the source of the tests or the wrappers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 365d6a7023 i18n: French for the new import messages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 964985b68c EPS import: adapt text runs to inline art, refresh Cargo.lock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 319e60125f EPS import: say plainly that no Adobe specification was used
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 2f0ede9c44 EPS import: leave out text objects that the page doesn't draw
Type turned to outlines leaves its text object in the file, which the app that
wrote it doesn't draw (it paints every shown text object on the page). A text
object made from the file's text document that shows on the page's area where the
page has no type, and without which the layers look no worse against the page, is
left out, with a note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 94dfa20fa0 EPS import: joined text lays out its bounds afresh
The pieces joined into one text kept the layout cached for the first piece, so the selection box covered only that piece.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 2dc738b816 EPS import: spell out abbreviated styles, and keep a line the page draws in pieces as one text
- A PostScript font name's shortened style words (Md, Lt, Bd, XBd, Cn, It, ...)
  weren't matched to installed faces, so AkzidenzGroteskPro-Md came in as
  the condensed face. They are spelled out before the face is looked up.
- A line the page draws in pieces (around kerning) came in as several text
  objects ("swe", "ep"); the pieces that add up to one line of the file's
  text object now join into one, with the line's spaces between them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 618b1cf94f EPS import: a commented copy of a text object that has a plain one isn't another object
Some files write a text object twice, once plainly and once as comments (and
others only as comments). Reading both made a second copy of the type, built from
the text document, over the page's own (wrong size and place). The commented copy
is now skipped when its story has a plain text object.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 ec43ac5a1e Read Illustrator EPS and .ai layers from their editing data
An Illustrator EPS is a flattened page plus, after %%EOF, an editing copy of
the document; a PDF-compatible .ai carries the same in its private data.
Importing only the page lost layer names, order, visibility, hidden layers
and hidden objects and hidden text.

- eps: read the editing copy (Zstandard %AI24_DataStream, older zlib
  %AI9_DataStream) into layers, sublayers, groups, compound paths, clip
  groups and hidden objects, run by the existing PostScript interpreter.
  Falls back to the page, with a warning, when a shown layer uses an
  operator that isn't read or the art differs from the page by over 5%.
- eps: read the text document (ate.rs) to place type on hidden layers and
  objects; the page's type is matched to the file's text objects by
  position (pieces of a line included) and kept with the nearest one when
  none claims it. Area type and type on a path are left out with a note.
- pdf/engine: .ai files go through the same reader.
- engine/cli: a file whose import left things out isn't overwritten unless
  asked (acknowledgeLoss, --replace-lossy).
- eps: font names re-encoded with a *<n> suffix resolve to their family.
- i18n: es/it/ru for the new import messages; ROADMAP updated.

Container and text-document formats were inferred from user-owned files; the
Adobe file format specification was not read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
bflatasticandClaude Opus 5.5 f8138e14ba Fix #546: DXF export writes each fill's outlines before its hatch
Laser and cutter software skips HATCH entities, so shapes that were only
filled (type, traced art) reached it as nothing. Each filled area is now
written as its outlines (polylines and splines, in the fill's colour)
followed by the solid hatch; R12 keeps writing the outlines alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:49:42 +07:00
BFlat f149f10059 Merge pull request #707 from DisasterAreaDesigns/fix-reencoded-font-names
EPS import: a font name re-encoded as <subset>+<name>*<n> names the font
2026-10-09 14:46:46 +07:00
BFlat a3f6eeb5cc Merge pull request #711 from storytold/fix/708-unmapped-glyphs
Fix #708: glyphs whose ToUnicode is U+FFFD don't import as "�"
2026-10-09 14:43:03 +07:00
bflatasticandClaude Opus 5.5 dc7c300c69 Fix #708: glyphs whose ToUnicode is U+FFFD don't import as "�"
A PDF or PDF-compatible .ai whose ToUnicode map gives some glyphs U+FFFD (as
some files do for the digits of an embedded CID font) imported those glyphs as
type showing the replacement character, so every digit came in as "�".

U+FFFD now counts as no mapping. Such a glyph takes its character from the
embedded font where it can (Adobe's CID tables for CID-keyed CFF, as before,
then the font program's own cmap), and otherwise keeps its outline from the
embedded font, so it looks as the file draws it. The import warns which font
had characters the file doesn't name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:34:17 +07:00
BFlat a9106b17a8 Merge pull request #709 from storytold/fix/703-templates-folder
Fix #703: the Templates folder is in the desktop's documents folder
2026-10-09 14:27:01 +07:00
bflatasticandClaude Opus 5.5 a52ad5cac8 Fix #703: the Templates folder is in the desktop's documents folder
New from Template... and Save as Template opened their dialog in
~/Documents/VectorCraft Templates. On desktops whose documents folder has
another name (Italian ~/Documenti) or where the folder was never made, the
dialog started in a folder that doesn't exist and showed an error.

- The default Templates folder is now in the user's documents folder: on Linux
  and the BSDs the one XDG_DOCUMENTS_DIR names in user-dirs.dirs, elsewhere
  ~/Documents.
- The folder a dialog starts in is made when missing; where it can't be, the
  dialog starts in the nearest folder above it that exists, else home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:22:32 +07:00
BFlat acd5dec214 Merge pull request #705 from storytold/fix/701-rotate-cursor
Fix #701: the rotate cursor is a curved double arrow
2026-10-09 14:17:26 +07:00
Matthew FarrowandClaude Sonnet 5.5 003ce917df EPS import: a font name re-encoded as <subset>+<name>*<n> names the font
A program that re-encodes a font in an EPS sets the copy under a name with a
`*<n>` suffix (`KBKNNM+RollerBabyBV*1`). The suffix was part of the family
name, so the installed font was never found and text fell back to a
substitute. The suffix is dropped when `*` is followed only by digits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:16:11 -04:00
bflatasticandClaude Opus 5.5 aef5790e01 Fix #701: the rotate cursor is a curved double arrow
The rotate cursor drew an arc with a large triangle under each end, both
pointing straight down and away from the arc, so it didn't read as an arrow.
Each end now has a slim arrowhead pointing on along the arc's tangent, with
the same halo as the other cursors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:13:03 +07:00
BFlat f59be80671 Merge pull request #702 from storytold/pr/land-698-699
Land #698, #699: a saved multi-artboard .ai reopens as its document; FreeBSD hexagon test
2026-10-09 14:05:00 +07:00
bflatastic b411a3d158 Merge #699 2026-10-09 13:57:37 +07:00
bflatastic a27c912f8a Merge #698 2026-10-09 13:57:37 +07:00
BFlat 5d74c5bb77 Merge pull request #700 from storytold/fix/696-field-widths
Fix #696: the Properties panel's number fields are all one width
2026-10-09 13:57:14 +07:00
Jax SpenserandClaude Sonnet 5.5 29310175af Image Trace: Flat Logo mode (sub-pixel edges, restored corners, true lines and circles)
A fourth Image Trace mode for flat-colour logos. The existing tracer follows hard pixel
edges, so an outline can only be as good as the pixel grid; here each pixel is read as a
blend of the logo's flat colours, the blend is interpolated smoothly (cubic B-spline), and
every outline is placed where two colours are equally present, to a fraction of a pixel.

Outlines are then cleaned the way a person would:
- corners the blur rounded are put back (line fits either side of a bend, intersected);
- near-straight runs and near-circular arcs become exact lines and arcs (Snap Curves To Lines);
- the rest is fitted with as few cubic Beziers as the tolerance allows (Schneider);
- touching colours overlap by 0.06 px so no hairline gap opens between independent fits;
- shapes are painted biggest first; transparent areas fully enclosed by artwork (letters
  knocked out of a badge, the counters of an "o") are painted white so the logo reads the
  same on any background, or become real holes with Ignore White (each hole belongs to the
  smallest outline containing it);
- an outline whose fit strays more than 1.4 px from the raw contour is fitted plainly.

Colours are found automatically (and completed from pixels no blend of the known colours
explains, so a small accent is not lost) or named with `logoColors: ["#rrggbb", ...]` (white is
always its own class; colours closer than 12 count as one; at most ten). Gradients and photos
are refused with advice to use Color (a flat-colour score of the solid areas, 0.93). A
hard-edged source with no anti-aliasing is softened by under a pixel and fitted with looser
snap tolerances. Images over 1024 px on the long side are averaged down first (further, if
they are busy enough to make the unmixing expensive) and the result is mapped back exactly.
More real outlines than we will fit are refused, not silently truncated.

Also: a "Flat Logo" preset, the mode in the Image Trace panel (Method hidden: it always
stacks), the command docs and MCP prompt, es/fr/it/ru translations for the two new messages
and the mode name, and the ROADMAP.

Never-crash: no unwrap/expect/panic, every size and float is bounded and checked, with
tests for degenerate and extreme input (NaN/infinite parameters, 0x0 and 1x1 images, sparse
outlines, an anchor budget, a 200,000-entry palette) and a 250-run randomised no-panic
property test. The B-spline interpolation follows Unser et al. and Thevenaz et al., and the
curve fitter follows Schneider (Graphics Gems); both are written from the papers.

Measured (local experiment, not in CI): a flat-colour logo's vector original rendered down
to a soft 199x100 px PNG and traced. Mean edge error vs the original at 1600 px wide:
built-in colour presets 2.5 to 3.4 px, Flat Logo 0.78 px (0.48 px from a crisp source);
region overlap 0.88-0.91 vs 0.966. About 0.1 s for a 200 px logo.

Tests: 60 new in the trace crate (unmix, field, contours, geometry, Beziers, corners,
outline fitting, palette, end-to-end on synthetic shapes made in code) and 3 in the engine.
cargo xtask ci passes (fmt, clippy, test, assets, brands, layers, wasm): 4,290 tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 17:57:04 +11:00
bflatasticandClaude Opus 5.5 fa3d78a4b2 Fix #696: the Properties panel's number fields are all one width
The Transform fields were sized from the row's room, the rotation field was
70 pt, the corner radius 80, Opacity 64, the stroke weight 90 and Keyboard
Increment 80, so the fields didn't line up. One `panels::field_width`, the
Transform fields' width measured from the full row, now sizes all of them (and
the Edit Artboards fields, and the Transform panel's corner radius).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:54:25 +07:00
llduyll10andClaude Opus 5.5 1056a42a56 Open: a saved .ai with several artboards restores its document instead of asking for pages
Opening a PDF-compatible .ai (or a PDF saved with Preserve Editing) that has more than one
page showed the Import PDF dialog and imported pages, although the file carries its
VectorCraft document. Opening now restores that document (every artboard and layer) without
asking, as the engine already does when no pages are picked. Placing still asks for the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:48:37 +07:00
BFlat d464f28ed3 Merge pull request #697 from storytold/pr/land-694
Land #694: Radial Blur, Smart Blur and Unsharp Mask
2026-10-09 13:47:27 +07:00
bflatasticandClaude Opus 5.5 83b51ab9c7 SVG export: name the unsupported pixel effects without a product name
The brands check keeps other products' names out of user-facing text; #694's
SVG warnings called Radial Blur, Smart Blur and Unsharp Mask "Photoshop-style
raster effects". They are "pixel effects" now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:43:49 +07:00
callmeBallochandClaude Opus 5.5 3e94f6ffd6 M2.9: hexagon orientation no longer depends on atan2 rounding
The Shaper fits a hexagon's rotation by scoring 0, 90, 180 and 270
degrees, but a hexagon at 180 is the one at 0 (and 270 the one at 90):
those pairs score the same up to rounding, so the platform's atan2 picked
between them. FreeBSD's libm gave 270 where Linux gives 90, failing
tilted_rectangles_ellipses_and_hexagons_snap_to_their_angle_steps on the
FreeBSD build+test job. Candidates a whole number of periods from an
earlier one are now dropped. A new test reproduces the flip on Linux
with the old code (180 instead of 0 at tilt -9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:37:59 +00:00
bflatastic 5acc269a41 Merge #694: Radial Blur, Smart Blur and Unsharp Mask
# Conflicts:
#	ROADMAP.md
2026-10-09 13:36:38 +07:00
BFlat c4e20023de Merge pull request #695 from storytold/fix/693-artboard-paste
Fix #693: a click activates its artboard; in-place pastes go onto it
2026-10-09 13:36:14 +07:00
bflatasticandClaude Opus 5.5 e9bbf43d15 Fix #693: a click activates its artboard; in-place pastes go onto it
- A press with a selection tool inside an artboard makes it the active one, as
  the Artboard tool's does; before, only the navigator and the Artboards panel
  changed it.
- Paste in Place, in Front and in Back take `artboard` (the UI passes the active
  one): the objects go where they were on the artboard they were copied from,
  on that artboard. Without it, they paste where they were, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:32:44 +07:00
bflatasticandClaude Opus 5.5 1e7f5084d8 Part of ROADMAP gap 2: Radial Blur, Smart Blur and Unsharp Mask on a pixel-filter pipeline
Effect › Blur › Radial Blur (spin/zoom, draft/good/best) and Smart Blur
(Normal mode), Effect › Sharpen › Unsharp Mask: Photoshop-style filters in
vectorcraft_effects::pixel, run by the renderer on the object's pixels in
stack order with Gaussian Blur and Feather, cached while panning; lengths in
points, bounded work per pixel. PDF, Expand Appearance and SVG export write
the effected objects as images. Dialogs with preview (method/quality
dropdowns, fields in documented order), menu entries, translations, tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:30:31 +07:00
BFlat 843507549f Merge pull request #692 from storytold/fix/windows-taskbar-icon
Windows: the taskbar and Alt+Tab show VectorCraft's icon
2026-10-09 13:15:39 +07:00
bflatasticandClaude Opus 5.5 b554330d06 Windows: the taskbar and Alt+Tab show VectorCraft's icon
Windows shows a window's big icon (ICON_BIG) in the taskbar and Alt+Tab.
eframe sets only the window icon (the small one, in the title bar), so the
taskbar showed a generic window instead of VectorCraft's icon. The app now sets
the big icon from the same 256 px image when the window is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:12:25 +07:00
BFlat eec150e276 Merge pull request #691 from storytold/pr/land-667
Land #667: native Affinity import with artboards, vectors, text and images
2026-10-09 12:56:00 +07:00
bflatastic 2de7257736 Merge #667: native Affinity import with artboards, vectors, text and images 2026-10-09 12:48:31 +07:00
BFlat 256702bab0 Merge pull request #690 from storytold/fix/663-toolbar-labels
Part of #663: Show Tool Group Labels option; Cmd+, opens Settings on the Mac
2026-10-09 12:44:53 +07:00
bflatasticandClaude Opus 5.5 498a2d1e6b Part of #663: Cmd+, opens Settings on the Mac
Cmd+, is how every Mac app opens its settings. It now opens Preferences on
macOS beside its own Cmd+K, unless a command or one of the user's shortcuts
already has the chord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:38:29 +07:00
bflatasticandClaude Opus 5.5 4ab13dabfb Part of #663: User Interface > Show Tool Group Labels
The toolbar names its tool groups (Select, Shapes, Draw, Modify...). A new
preference, on by default, turns the names off for a cleaner toolbar: a faint
dash then separates the groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:38:28 +07:00
BFlat f1157456a1 Merge pull request #689 from storytold/pr/land-687
Land #687: Rearrange All Artboards layouts, order and one click (fix #681)
2026-10-09 12:33:16 +07:00
bflatastic 55cc14e4a8 Merge #687: Rearrange All Artboards layouts, order and one click
# Conflicts:
#	ROADMAP.md
2026-10-09 12:28:10 +07:00
BFlat dce0f8a48d Merge pull request #688 from storytold/fix/673-tab-close-side
Part of #673: a document tab's close box sits where the platform puts it
2026-10-09 12:27:41 +07:00
bflatasticandClaude Opus 5.5 6a39ee0a1c Part of #673: a document tab's close box sits where the platform puts it
Document tabs drew their close box before the title on every platform, the
macOS convention. On Windows, Linux and the web it now comes after the title,
as those platforms' tabs (and Adobe's own apps there) put it; macOS keeps it
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:24:25 +07:00
bflatasticandClaude Opus 5.5 01df2c730b Fix #681: Rearrange All Artboards layouts, order and one-click access
- artboard.rearrange takes layout (gridByRow, gridByColumn, row, column;
  byColumn stays as an alias of gridByColumn) and order (leftToRight,
  rightToLeft); Columns is the row count of a grid by column and is
  ignored by row and column. It returns {artboards, rows, columns}.
- A Rearrange All Artboards dialog: Layout and Layout Order toggles drawn
  in code (widgets::layout_button, previewing the order), Columns/Rows,
  Spacing, Move Artwork with Artboard and the artboard count.
- One click: Rearrange All in the Properties panel (Document section with
  several artboards, Edit Artboards' Quick Actions); the Artboards panel's
  Rearrange button and menu item now open the dialog too.
- Tests for every layout x order (artboards, art riding along, one undo
  step), the dialog, the Properties button; the command sweep covers the
  new params. Translations for ja, es, it, ru, fr, zh-hans, zh-hant and
  pt-br; ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:22:48 +07:00
BFlat dcc0de7487 Merge pull request #686 from storytold/fix/663-canvas-greys
Part of #663: Canvas Color offers light, medium and dark grey
2026-10-09 12:20:42 +07:00
callmeBallochandClaude Opus 5.5 761b8fc080 AF.3: state the Affinity reader's clean-room provenance
The crate README now spells out the provenance the review on
storytold/vectorcraft#667 asks the owner to decide on: no Affinity
software; afread's MIT description re-described without translating
code; every structure confirmed on public files (CRC-32 per entry,
self-typed fields); semantics fitted to Affinity's own thumbnails; how
the sample documents were licensed; read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:20:33 +00:00
callmeBallochandClaude Opus 5.5 5d41601da1 Merge storytold/main into AF.3 native Affinity import
Conflicts: ROADMAP (main's guides and file-format rows, with the Affinity
items kept); fileio/load.rs (main's physical-size raster documents, kept
visible to the Affinity preview fallback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:20:33 +00:00
bflatasticandClaude Opus 5.5 0924bc16ae Part of #663: Canvas Color offers light, medium and dark grey
Preferences > User Interface > Canvas Color offered Match User Interface
Brightness and White only: with a dark interface the first is a high-contrast
dark canvas around the white page, and White hides where the page ends. Light
Gray, Medium Gray and Dark Gray keep the page's edge in view at a gentler
contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:14:33 +07:00
callmeBallochandClaude Opus 5.5 e80e47a295 AF.3: count distinct sample documents in the value-budget note
The census behind the limit counted duplicate copies of the same public
files: there are 189 distinct documents, the largest still decoding 3.3
million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:13:22 +00:00
BFlat f18cd1c99b Merge pull request #685 from storytold/fix/683-fonts-folder
Fix #683: Preferences › Type › Additional Fonts Folder
2026-10-09 12:11:11 +07:00
bflatasticandClaude Opus 5.5 e92bbdbdac Fix #683: Preferences > Type > Additional Fonts Folder
VectorCraft found fonts only in the system's font folders (and, on Windows,
those font services activate). A new preference names one more folder, read
with its subfolders after the platform's: its fonts are listed in the font
menus and used as if installed, without installing them.

- vectorcraft_text::set_user_font_dirs / user_font_dirs: folders
  system_font_dirs adds to every scan (none on wasm).
- prefs fontsFolder (Type > Options > Additional Fonts Folder), trimmed; when
  it changes after the first scan, the fonts are scanned again at once, so the
  folder's fonts appear (or go) and type in them lays out again.
- The label is translated where Additional Plug-ins Folder is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:05:16 +07:00
BFlat b61089e5c6 Merge pull request #684 from storytold/pr/land-fastmikey
Land #674, #676, #677, #678, #679: raster resolution, Crop Image trim, CMYK TIFF alpha, masked CMYK PDF images, direct ICC CMYK to RGB
2026-10-09 11:56:43 +07:00
bflatasticandClaude Opus 5.5 7bd496b1e2 Images: the web app keeps the decoder's 512 MB limit
#677 lets one image decode take up to 2 GB, so large print sheets open on the
desktop. wasm32 can address only 4 GB and aborts when an allocation fails, so
the web app keeps the decoder's default 512 MB and reports larger images as an
error instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:43:59 +07:00
bflatastic 2b91d7effd Merge #679 2026-10-09 11:43:42 +07:00
bflatastic b062d625cf Merge #678 2026-10-09 11:43:42 +07:00
bflatastic ee7be83ee0 Merge #677 2026-10-09 11:43:41 +07:00
bflatastic 50bb3b2693 Merge #676 2026-10-09 11:43:41 +07:00
bflatastic 2f81b4437b Merge #674 2026-10-09 11:43:40 +07:00
BFlat 23b7c68d90 Merge pull request #680 from storytold/fix/671-artboard-orientation
Fix #671: Edit Artboards' Properties picks a size preset and flips the orientation
2026-10-09 11:42:03 +07:00
bflatasticandClaude Opus 5.5 4176c0f297 Fix #671: Edit Artboards' Properties picks a size preset and flips the orientation
With the Artboard tool, the Properties panel showed the active artboard's name,
position and size but no quick way to turn it between portrait and landscape.
It now has a row with:
- Preset: New Document's saved and built-in presets, by category, matched in
  either orientation (Custom when none matches); choosing one sizes the
  artboard in its current orientation;
- Portrait and Landscape (the New Document toggle): the other one swaps the
  artboard's width and height.
The artboard's top-left corner stays put, so no art moves with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:37:05 +07:00
storybored379andOpenAI Codex 829ec48581 M1: command catalog resource and version-aware MCP results
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-08 21:35:07 -07:00
BFlat 349668a1e6 Merge pull request #672 from storytold/pr/land-394
Land #664, #669: Animated Zoom, Large Tabs, Scale Cursor, anti-aliased bitmaps, Spacing Guides (part of #394)
2026-10-09 11:32:46 +07:00
callmeBallochandClaude Opus 5.5 67a62a7fe7 AF.3: bound the values a document stream decodes
A value takes about 40 bytes in memory however few it took in the file,
and a bool array packs eight in a byte, so a small, highly compressed
doc.dat could declare billions of values and make the import abort on
allocation (review on storytold/vectorcraft#667). The parser now counts
fields and array elements against a 2^24 budget before allocating, and
reserves array storage fallibly, returning Error::Limit either way.
The largest of 333 public documents decodes 3.3 million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:31:42 +00:00
storybored379andOpenAI Codex 2eb6b2fd7c M1: common MCP tools and strict argument handling
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-08 21:26:41 -07:00
callmeBallochandClaude Opus 5.5 b5ae419734 Merge storytold/main into AF.3 native Affinity import
Conflicts: README and ROADMAP (both sides kept: the French and Russian
interface with the Affinity sentence; the placed-documents bullet with
the Affinity one); place and swatch libraries (upstream's byte sniffing
and comments, with the preview-only refusal kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:26:07 +00:00
bflatasticandClaude Opus 5.5 ea07805fdf ROADMAP: Spacing Guides wired (#669) beside #664's preferences
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:26:01 +07:00
bflatastic e29e558810 Merge fix/394-spacing-guides
# Conflicts:
#	ROADMAP.md
2026-10-09 11:25:41 +07:00
bflatastic cc627c75e3 Merge fix/394-prefs-batch
# Conflicts:
#	ROADMAP.md
2026-10-09 11:25:38 +07:00
BFlat 5f4e03f3b3 Merge pull request #670 from storytold/fix/661-touch-bar-quit-int
Fix #661: quitting no longer crashes on Macs with a Touch Bar
2026-10-09 11:24:59 +07:00
fastmikey99andClaude Opus 5.5 f767403673 Crop Image: trim an image to its pixels that aren't fully transparent
object.cropImage {trim: true} cuts the selected image to the box of its pixels
with any alpha, copying them as they are (no re-render, no resampling) and
placing them where they were. An image with nothing to cut is left as it is
and isn't an error (trimmed: false), so a script can trim every image it
opens. Print-ready PNGs usually carry transparent padding; with this, Fit to
Artwork Bounds can size to the art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:24:04 +13:00
bflatasticandClaude Opus 5.5 90d6785eb5 Part of #394: Animated Zoom, Large Tabs, Scale Cursor and anti-aliased bitmaps act
- Performance › Animated Zoom (with GPU Performance, which dims it when
  off): the Zoom tool dragged sideways zooms about the press (right in,
  left out) and held still zooms on (Alt: out); a click still steps. Off,
  a drag zooms to the area dragged across, now drawn as a marquee. The
  hint bar says which.
- User Interface › Large Tabs: taller document tabs with larger titles.
- User Interface › Scale Cursor Proportional to UI: off, the tool cursors
  keep their size whatever the UI Scaling (OS and painted cursors alike).
- File Handling › Display Bitmaps as Anti-aliased Images in Pixel
  Preview: off (the default), Pixel Preview samples images nearest-pixel
  (`RenderOptions::smooth_images`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:22:43 +07:00
bflatasticandClaude Opus 5.5 1575b14294 Part of #394: Smart Guides › Spacing Guides space a moved selection evenly
A selection moved with the Selection or Direct Selection tool that lines
up with nothing along an axis lands as far from its neighbour as two
other objects of its row (or column) are apart, or halfway between its
two neighbours, within the snapping tolerance; the equal gaps are marked
with a line and end ticks in the Smart Guides colour. The objects are
taken as wholes on their layers (groups too), and inside a group holding
the selection, its other objects. Off, moves only line up as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:22:22 +07:00
bflatasticandClaude Opus 5.5 f17bac2c64 Fix #661: quitting no longer crashes on Macs with a Touch Bar
On every quit (Cmd+Q, VectorCraft > Quit, the close button) on a MacBook Pro
with a Touch Bar, AppKit threw from -[_NSTouchBarFinderObservation invalidate]
and macOS reported a crash. AccessKit's macOS adapter subclasses the window's
content view and gives it back its own class when the window is torn down;
AppKit's Touch Bar support observes the view under the subclass, so removing
that observation throws (the same cause Zed fixed in zed-industries/zed#65186).

On macOS with accessibility, the app now ends the process at the end of
on_exit, once the preferences are saved, before eframe tears the window down.
Nothing after the window is needed on a normal quit. While the window is still
starting up it doesn't, so a graphics failure still starts the app again on
another adapter (#651).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:20:40 +07:00
BFlat 01ad745e86 Merge pull request #668 from storytold/fix/663-recent-and-preset-dblclick
Part of #663: Home screen Recent Files; a double-clicked preset creates the document
2026-10-09 11:20:12 +07:00
bflatasticandClaude Opus 5.5 d8f6bacddb Part of #663: Home screen Recent Files; a double-clicked preset creates the document
- The Home screen lists the first six of File > Open Recent Files, each by name
  (a click opens it) and folder; one that can't be opened says so in the status
  bar. With none, there is no section.
- In New Document, a double-click on a preset card creates the document from
  it; a click still only chooses it. The Create buttons no longer overwrite a
  create asked for earlier in the frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:17:35 +07:00
fastmikey99andClaude Opus 5.5 2cdf4e3de0 Colour: ICC CMYK converts straight into the RGB profile, with black-point compensation
CMYK went to RGB through display sRGB: CMYK -> sRGB (clamped) -> working RGB.
Into a wider space (Wide Gamut RGB, the 1998 primaries) that clipped every
colour sRGB can't hold - CMYK cyan's red channel landed at 99 instead of 0 -
and the black-point compensation setting had no effect (moxcms leaves it
unimplemented). An ICC CMYK profile now converts directly into the RGB
profile: into its primaries with linear curves, where compensation is a
linear remap from the profile's black (found by sending black through the
profile and reading it back), then onto its own curves. The canvas uses the
same conversion into sRGB, so what shows is what an RGB export writes.

Checked against Ghostscript with the same profiles (relative colorimetric,
compensation on and off): exports into Wide Gamut RGB and the sRGB display
agree within 1 level in 255. The built-in CMYK spaces are unchanged (Generic
CMYK compensates in its own model).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:34 +13:00
fastmikey99andClaude Opus 5.5 088495ee17 PDF import: masked CMYK images stay CMYK, their mask an opacity mask
A CMYK image with an /SMask, /Mask or colour key was handed to the
interpreter and stored as an RGB PNG, converted with hayro's own bundled
profile (Perceptual) rather than the working CMYK, so it showed 10-30 levels
lighter than other CMYK images and lost its ink amounts. It now keeps its
inks as other CMYK images do (#320), and its alpha becomes a greyscale
opacity mask. PDF export already writes that as a DeviceCMYK image under a
soft mask, so a round trip keeps the inks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:34 +13:00
fastmikey99andClaude Opus 5.5 43db1cdbbf Open CMYK TIFFs with an alpha channel, and rasters past 512 MB
The image decoder can't read a CMYK TIFF with an alpha channel at all
("color type Unknown(0)"); it is now read with the tiff crate, associated
alpha divided back out and the inks converted in the active colour settings.
And a 60 in sheet at 300 ppi (18000 x 6600 px) hit the decoder's 512 MB
default allocation limit; up to 2 GB is now allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:08 +13:00
fastmikey99andClaude Opus 5.5 ed17166343 Open raster images at the size their resolution declares
An image opened as a document took 1 px = 1 pt whatever its pHYs, JFIF or
EXIF resolution said, so a 300 ppi print file opened at four times its
physical size. It now opens the way Place already sizes it (pt_per_px):
at the resolution it declares, 72 ppi when it declares none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:07 +13:00
BFlat fc74389b5a Merge pull request #660 from Haven-King/fix/text-engine-integration
Fix/text engine integration
2026-10-09 10:45:11 +07:00
BFlat 0891ffbf51 Merge pull request #659 from storytold/fix/449-two-finger-pan
Part of #449: two fingers dragged on a touch screen pan the canvas
2026-10-09 10:37:26 +07:00
bflatasticandClaude Opus 5.5 861a0759b0 Part of #449: two fingers dragged on a touch screen pan the canvas
A pinch already zoomed the canvas and a second finger already cancelled the
first finger's press, but dragging two fingers together did nothing: the
canvas only read wheel scrolls, and a touch screen sends touches. The fingers'
movement (egui's multi-touch translation) now scrolls the canvas under either
Zoom with Mouse Wheel setting, and zoom and scroll apply in the same frame, so
a pinch that also slides pans too. Trackpads send scrolls, so nothing counts
twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:34:22 +07:00
haven 992f6a0a4d M7: tests for the text engine features combined
Layout (vectorcraft-text tests_combos): every pair of ligatures under tracking,
the paragraph composer, vertical alignment, Shrink Text to Fit, per-paragraph
attributes and inline graphics, and all six at once. Commands
(vectorcraft-engine tests_textcombos): Auto Size and Shrink Text with inline
art, paragraphs, composer and alignment; editing and undo across them; a save
round trip with everything on.
2026-10-08 20:30:14 -07:00
haven 53e39884fc M7: Auto Size measures vertically aligned area type from the top
auto_height lays the text out in a very tall probe frame; with Center, Bottom
or Justify the lines moved down inside it and the frame grew to tens of
thousands of points.
2026-10-08 20:30:14 -07:00
haven abdc26d8d1 M7: Shrink Text to Fit scales inline art's baseline shift
The fit scaled each run's size, leading and baseline shift but not an inline
graphic's own shift (points), so shrunk symbols sat off the cap-height centre.
2026-10-08 20:30:14 -07:00
haven 56c975465c M7: inline art follows vertically aligned lines
align_vertically moved lines and glyphs but not TextLayout::inlines, so inline
symbols in a centred or bottom-aligned area stayed where top alignment put them.
The reflow path for non-rectangular frames also kept the previous pass's
inlines, drawing each symbol twice.
2026-10-08 20:30:14 -07:00
BFlat 2dcac5b48b Merge pull request #658 from storytold/fix/651-gpu-fallback-int
Fix #651: a dual-GPU Mac falls back to its other GPU
2026-10-09 10:29:33 +07:00
bflatasticandClaude Opus 5.5 dc39146d0d Fix #651: a dual-GPU Mac falls back to its other GPU
Metal reports no PCI ids, so both GPUs of a dual-GPU Mac had the adapter key
Metal:0000:0000. When the integrated GPU failed at start-up, the restart left
out both and the app quit, although the discrete GPU works. An adapter without
ids is now keyed by its name, so the restart leaves out only the one that
failed.

The failure itself was wgpu's indirect-call validation compute shader, which
OCLP-patched Metal on an Iris Pro can't compile. Nothing in VectorCraft or
egui draws indirectly, so that validation is off (WGPU_VALIDATION_INDIRECT_CALL=1
turns it back on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:26:55 +07:00
BFlat 147fc113c3 Merge pull request #657 from storytold/pr/land-641
Land #653, #654, #656: Image Trace View modes, Palette and Create Strokes (part of #641)
2026-10-09 10:25:04 +07:00
bflatastic 66a5363629 Merge #656: Image Trace: Create Strokes
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/buildcmds.rs
#	crates/engine/src/tests_build.rs
#	crates/mcp/src/prompts.rs
#	crates/trace/src/tests.rs
#	crates/ui-egui/src/panels/image_trace.rs
2026-10-09 10:16:02 +07:00
bflatastic eea4fe9e58 Merge #654: Image Trace: Palette (Limited, Full Tone, Automatic, Document Library)
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/buildcmds.rs
#	crates/ui-egui/src/panels/image_trace.rs
2026-10-09 10:14:35 +07:00
bflatastic 13c37e0bf4 Merge #653: Image Trace: Advanced as a section that stays open, and View modes
# Conflicts:
#	ROADMAP.md
2026-10-09 10:13:04 +07:00
bflatasticandClaude Opus 5.5 438f90c751 Part of #641: Image Trace: Create Strokes
Advanced › Create: Fills and/or Strokes, with Stroke (the widest line, px).
With Strokes, each colour's 8-connected parts no wider than the stroke width
(chamfer distance transform) trace as open centre lines instead of filled
outlines: the line is thinned to one pixel (Zhang-Suen with each removal
re-checked, so two-pixel lines survive; staircase corners dropped), its
skeleton becomes runs between ends and junctions (junction pixels merged,
spurs pruned, runs through two-way junctions joined, rings closed), and each
run is simplified and fitted with curves. The stroke width is the line's mean
width (pixels over length). Traced lines get round caps and joins in the
document. Without Fills, wider areas become 1 px stroked outlines.
`imageTrace.make` takes fills, strokes and strokeWidth, and refuses neither
fills nor strokes and a non-positive width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:11:09 +07:00
bflatasticandClaude Opus 5.5 4a81e549a2 Part of #641: Image Trace: Palette (Limited, Full Tone, Automatic, Document Library)
Color mode's colours now come from a Palette (`palette` in imageTrace.make's
params), shown in the Image Trace panel under Mode:
- Limited: the `colors` best colours (what Color did before; the default).
- Full Tone: median cut until no colour box spans more than 96 (Less) … 8
  (More) levels per channel, up to 256 colours, set by `colorDetail` 0-100
  (the panel's Colors slider for this palette).
- Automatic: flat art (90% of the pixels in a few tight colours) traces with
  exactly its colours, anything else as Full Tone.
- Document Library: the most used (at most `colors`) colours of the
  document's swatches or of a swatch library (`library`: "document" or a
  library id or name, chosen in the panel's Library list), used as they are;
  traced shapes fill with the swatch's own colour, its colour model kept.
  The library's colours are looked up at every trace, not saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:51:49 +07:00
bflatasticandClaude Opus 5.5 5be583d1f7 Part of #641: Image Trace: Advanced as a section that stays open, and View modes
- The Image Trace panel's Advanced options sit under a full-width collapsible
  header (widgets::section_toggle, also used by New Document's Advanced
  Options); whether it is open is kept with the UI preferences.
- View: Tracing Result, Tracing Result with Outlines, Outlines, Outlines with
  Source Image and Source Image, in the panel, the Control bar and the
  Properties panel. The view is saved in the Image Trace object's settings
  (`trace.view`), kept when it is traced again, set with the new
  `imageTrace.setView` command (or `view` on imageTrace.make) and drawn by the
  canvas only (RenderOptions::trace_views): exports, printing and Expand keep
  the tracing result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:33:22 +07:00
BFlat 11fceda13e Merge pull request #650 from mateususa/fix/artboard-tool-delete-key
Artboard tool: Delete and Backspace delete the active artboard
2026-10-09 09:31:38 +07:00
BFlat ea814b3fac Merge pull request #652 from storytold/fix/649-dock-drop-zone
Fix #649: a panel dropped on the dock lights up where it goes
2026-10-09 09:27:11 +07:00
bflatasticandClaude Opus 5.5 b5e39048df Fix #649: a panel dropped on the dock lights up where it goes
Panels other than Properties, Layers and Libraries live in the dock's icon
column, not as tabs. Dragging one onto the dock lit up the whole dock, tabs
included, and the drop put it back as an icon, so it seemed to vanish. The
drop zone now lights up the icon column for such panels (the whole dock still
for the tabbed ones), and the dropped panel pops out of its icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:23:30 +07:00
mateususaandClaude Opus 5.5 762bd6b927 Artboard tool: Delete and Backspace delete the active artboard
The Artboard tool handled Delete/Backspace in Tool::key, but never claimed
the keys through Tool::claims_key. The UI only routes Delete/Backspace to a
tool that claims them, so the keypress went to the Clear shortcut instead
and the artboard stayed. The existing tests called the tool directly and
missed it.

The tool now claims Delete/Backspace while no drag is in progress and more
than one artboard exists. With only one artboard left, the keys stay with
the shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:19:59 -07:00
BFlat 033ceaff41 Merge pull request #648 from storytold/fix/svg-root-layer-name
Part of #372: SVG import names the one layer from the root element
2026-10-09 09:05:21 +07:00
bflatasticandClaude Opus 5.5 a2e4206155 Part of #372: SVG import names the one layer from the root element
Other editors export a document with a single layer without a layer group:
the layer's name goes on the root <svg> (id, or data-name) and its art sits
directly inside. Such a file opened as "Layer 1"; now its one layer takes the
root's name. Files whose top-level groups are the layers are unchanged, and an
Inkscape root's generated id (with inkscape:version) isn't taken as a name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:00:24 +07:00
BFlat 771981e2b1 Merge pull request #644 from dotCooCoo/feat/ase-import
Swatches: swatch exchange (.ase) files open as swatch libraries
2026-10-09 08:50:50 +07:00
BFlat c7aeaa637f Merge pull request: land #643 (fix #638)
Land #643 (fix #638: Layers panel lays out only rows in view, keeps open rows in the file)
2026-10-09 08:40:24 +07:00
bflatastic 965fc449e1 Merge #643: Fix #638: Layers panel lays out only the rows in view and keeps open rows in the file 2026-10-09 08:31:13 +07:00
bflatasticandClaude Opus 5.5 977e5d1967 Fix #638: Layers panel lays out only the rows in view and keeps open rows in the file
The panel laid out a row (with its thumbnail and several whole-document
lookups) for every open row each frame. A layer of 5,000 groups of 10 paths,
all open, took about 29 s a frame headless. Now the rows shown are listed once
per document change (with their layer colour and dimming worked out on the
way down) and ScrollArea::show_rows lays out only those in view: 0.4 ms a
frame. A row highlighted out of view is scrolled to.

The open rows move from UI state into the document's state
(DocState::layers_open, OpenRows): a document opens with only its top-level
layers open, and native files save the open rows (Document::layers_open,
written only when not the default, at save time like the view, so opening or
closing rows never marks the document modified).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:19:49 +07:00
BFlat b73e19eef2 Merge pull request: land #639 (part of #372)
Land #639 (part of #372: SVG import keeps wrapped layers, round-trips sublayers)
2026-10-09 07:45:18 +07:00
bflatastic 4792bd82a2 Merge #639: Part of #372: SVG import keeps wrapped layers and round-trips sublayers 2026-10-09 07:41:02 +07:00
bflatasticandClaude Opus 5.5 d5e6be1565 Part of #372: SVG import keeps wrapped layers and round-trips sublayers
Top-level groups already opened as layers named from data-name, else their
id. Now that also holds when one more id-less group wraps them (a transform
on all the art, or a <switch>'s chosen group), and hidden objects inside a
<switch> are kept (hidden) instead of dropped.

Nested groups stay groups unless something marks them as layers: the SVG
export now writes data-vc-layer="sublayer" on layers inside layers, and
import makes such a group (or an Inkscape layer group) a sublayer of the
layer holding it, in that layer's colour. A marked group anywhere else
stays a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:36:21 +07:00
BFlat a469568c7d Merge pull request #636 from HEOJUNFO/pixel-preview-pixels
View › Pixel Preview: show the document pixels the art rasterizes to
2026-10-09 06:57:47 +07:00
heojunfoandClaude Opus 5.5 53b4845f6f View › Pixel Preview: show the document pixels the art rasterizes to
Pixel Preview only switched the pixel grid on from 600%: the art was still
rendered for the screen and drawn with linear filtering, so curves and
strokes stayed smooth vectors. Now, while a document pixel is bigger than a
screen pixel, the canvas renders the visible document pixels one per point
(anti-aliased by coverage), uploads them with nearest filtering and places
them over the document rect they cover, turning with a rotated view.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:49:18 +09:00
BFlat 5f92f5eb7f Merge pull request: land #631, #632
Land #631 (ICF character alignment) and #632 (French interface)
2026-10-09 06:34:55 +07:00
bflatasticandClaude Opus 5.5 409af66699 Tests: #631's ICF test builds its runs with #627's inline field
#627 added TextRun::inline while #631 was open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:26:49 +07:00
bflatasticandClaude Opus 5.5 65f35f20a6 French: ICF Top/Right and ICF Bottom/Left (#631 landed beside the catalog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:25:14 +07:00
bflatastic 2b10c15d7a Merge #632: Add French (fr) interface translation
# Conflicts:
#	ROADMAP.md
2026-10-09 06:24:09 +07:00
bflatastic 712f4316e0 Merge #631: Type: Character Alignment on the ICF top and bottom (ideographic character face)
# Conflicts:
#	ROADMAP.md
#	crates/text/src/layout.rs
2026-10-09 06:24:04 +07:00
Simon LeblancandClaude Opus 5.5 0716a0d104 Translate the French labels added on main
Free Transform's widget (Free Transform, Perspective Distort,
Constrain), the Properties panel's artboard editing (Artboard, Exit)
and Type › Insert Inline Symbol, found by the catalog completeness
tests after the rebase.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:19:54 +02:00
BFlat a29563fb92 Merge pull request: land #629
Land #629 (pasted type keeps its layout bounds)
2026-10-09 06:18:57 +07:00
Simon LeblancandClaude Opus 5.5 c640a04612 Translate the French strings drawn through translating helpers
These labels reach the screen through helpers that translate their
argument (dim_label, dropdown, tl!(CONST)), so the tl! literal scan
doesn't see them: the Libraries panel's empty state, Recolor Artwork's
1/2/3 Color Job presets, Spot Colors' CMYK Equivalents, the
Eyedropper's 3 x 3 / 5 x 5 Average, Print's PDF File destination and
Save PDF's CMYK profile in effect output intent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:25 +02:00
Simon LeblancandClaude Opus 5.5 e22d22a4ce Order the French code and docs alphabetically by language code
French now sits between Spanish and Italian (es, fr, it, ru): the
plural rule, the LANGUAGES registry entry (so the Language menu lists
Français before Italiano), the locale tests, the fr() helper and
french_reads_as_french. README, ROADMAP and docs/development.md list
the languages in the same order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:25 +02:00
Simon LeblancandClaude Opus 5.5 9ec940222b Add French (fr) interface translation
Add fr.tsv with the full interface coverage: every menu string, every
tl! literal, the plurals and the status/error messages (@msg).
Registered as complete_menus with the French plural rule (0 and 1 are
singular); every fr-* locale (fr-FR, fr-BE, fr-CA, fr-CH …) resolves
to it. Keeps the product, workspace and perspective preset names in
English like the other complete catalogs.

Tests: french_reads_as_french; fr added to KEEPS_MENU_NAMES and
COMPLETE_MESSAGES; the Latin-script glyph test covers French letters
and the no-break space; locale tests use de-DE as the unsupported case.

Clean-room: written from the meaning of the English labels in the
vocabulary French-speaking users of vector illustration software know
(Plan de travail, Nuancier, Contour, Fond, Tracé, Calque). No other
application's localisation was copied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:14 +02:00
bflatastic 9e2fb37f3a Merge #629: Fix pasted type losing its layout bounds cache
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-09 06:14:31 +07:00
imachusandClaude Opus 5.5 4411688b3d Type: Character Alignment on the ICF top and bottom (ideographic character face)
Character panel menu › Character Alignment gains ICF Top/Right and ICF Bottom/Left, and
`charAlign` takes `icfTop` / `icfBottom`: a character smaller than the largest on its line lines
up its ideographic character face's top (right, vertical) or bottom (left) with the big one's.

`FontFace::icf_margins` places the ICF inside the em box as the OpenType baseline tags define it
(https://learn.microsoft.com/en-us/typography/opentype/spec/baselinetags): `icfb` / `icft` of the
BASE table's horizontal and vertical axes against `ideo` / `idtp` (else the OS/2 typographic
descender and one em), for Han ideographs (else kana, else the first script). Without them, as that
page allows, the average ink box of some ideographs and kana; a face without ideographs uses its
em box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:14:17 +09:00
BFlat 6b2c90f756 Merge pull request: land #627, #628
Land #627 (inline symbols in text) and #628 (comment cleanup)
2026-10-09 06:13:34 +07:00
Diego Gomez Garcia 1ee36c2c07 Fix pasted type losing its layout bounds cache
Type pasted or imported from another app (Illustrator, PDF, SVG) arrives
without cached_bounds, which is not serialized. TextObject::bounds() then
falls back to estimate_bounds(), a rough 0.55 em per character, so the
selection box came up short of the last character and alignment looked off
until the file was saved and reopened (Session::add_document refreshes it).

Refresh the bounds of pasted text in paste_clip, covering paste and paste in
front/back/in place and every clipboard flavour, with a regression test.
2026-10-09 01:05:11 +02:00
bflatastic f365dc1c74 Merge #627: M7: inline symbols in text 2026-10-09 06:02:59 +07:00
bflatastic e9de045f06 Merge #628: Type: comments on new type's burasagari default state the default only 2026-10-09 06:02:58 +07:00
BFlat 58a3a68d83 Merge pull request #625 from DWAA1660/fix/536-popover-swatch-libraries
Fix #536: the Fill/Stroke popover's Swatch Libraries button opens its menu
2026-10-09 06:02:12 +07:00
imachusandClaude Opus 5.5 a366e4ff79 Type: the comments on new type's burasagari default state the default only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:01:16 +09:00
BFlat 8b81d58845 Merge pull request: land #568, #570, #623
Land #568 (fix #563), #570 (fix #530), #623 (part of #579)
2026-10-09 05:57:51 +07:00
Claude d9b5eea18d Fix #536: the Fill/Stroke popover's Swatch Libraries button opens its menu
In the Fill or Stroke chip's popover (Control bar, Properties ›
Appearance), the Swatch Libraries Menu button at the foot of the
swatches closed the popover and showed nothing, as in the video on
#536; Show Swatch Kinds did the same. egui remembers one open popup per
window: the chip's popover was such a remembered popup, so the button's
menu, opening as another one, closed the popover and itself with it.

The popover now keeps its own open state with widgets::popover (made for
popovers that open menus of their own), so the libraries menu opens over
it and a library chosen there opens in the library panel. Escape, a
click outside and a click on the chip still close it.

Test: tests_paintchips::the_popover_swatch_libraries_button_opens_the_libraries_menu
(fails before this change: the popover closes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW
2026-10-08 16:53:57 -06:00
bflatasticandClaude Opus 5.5 8ccafe1187 Russian: Artboard and Exit for the Properties panel's artboard editing
#570 added them while the Russian catalog was landing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:53:30 +07:00
bflatastic f3b90fb93f Merge #623: Part of #579: vectorcraft-cli lists the fonts font services load too
# Conflicts:
#	ROADMAP.md
2026-10-09 05:48:40 +07:00
bflatastic fc052cb001 Merge #570: Fix #530: Edit Artboards adds, deletes and leaves artboards from Properties
# Conflicts:
#	ROADMAP.md
2026-10-09 05:48:30 +07:00
bflatastic 896f8efea8 Merge #568: Fix #563: the font menu's live preview no longer flickers
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/tests_font_menu.rs
2026-10-09 05:48:00 +07:00
BFlat 921e60f4fc Merge pull request: land #621 (fix #585)
Land #621 (fix #585: two-finger tap undoes, three-finger tap redoes)
2026-10-09 05:46:59 +07:00
haven b843fe8af3 M7: inline symbols in text
Document symbols placed inline in a run of type (beyond Illustrator; like
InDesign inline anchored objects): TextRun.inline holds the symbol, scale and
shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on
the cap height. text.insertInline and Type > Insert Inline Symbol; editing
keeps the graphic whole; canvas, SVG (<use>) and PDF draw the art; Create
Outlines turns it into a symbol instance.
2026-10-08 15:45:34 -07:00
bflatasticandClaude Opus 5.5 aa5e8f46e9 Part of #579: vectorcraft-cli lists the fonts font services load too
The DirectWrite font lister (#594) was installed in the desktop app only:
`vectorcraft-cli` (convert, run, MCP) didn't see fonts that font services
such as Adobe Fonts load on Windows, so its exports set such type in a
fallback font.

- The CLI shares the app's `system_fonts.rs` and installs it first thing
  on Windows (not the win7 build). The text crate stays free of unsafe
  code; the CLI denies unsafe code instead of forbidding it, so the
  shared module's own allowance applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:43:13 +07:00
bflatastic 9dec49ed03 Merge #621: Fix #585: a two-finger tap undoes, a three-finger tap redoes
# Conflicts:
#	ROADMAP.md
2026-10-09 05:42:35 +07:00
BFlat dc157c6c5b Merge pull request: land #619 (fix #598)
Land #619 (fix #598: the Free Transform widget)
2026-10-09 05:41:12 +07:00
bflatasticandClaude Opus 5.5 aa125bfff1 Fix #585: a two-finger tap undoes, a three-finger tap redoes
Preferences › Devices › Enable Touch Gestures (on by default) was stored
but nothing read it: no touch gesture was implemented.

- `touch::Taps` follows the fingers egui reports (winit reads them from
  the system: WM_POINTER on Windows): fingers down together and lifted
  within half a second, none moved more than 16 points, is a tap. Two
  fingers undo, three redo, with the preference on.
- egui makes the first finger the pointer too, so it has pressed on the
  canvas by the time a second lands: that press is dropped then (its tool
  interaction rolled back), and the rest of the gesture presses nothing.
  The tap so undoes what came before it, not the first finger's mark.
- A pen is one contact, so pen strokes and taps are unaffected; pinch zoom
  is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:39:47 +07:00
bflatastic 22008b4e68 Merge #619: Fix #598: the Free Transform widget
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-09 05:36:53 +07:00
bflatasticandClaude Opus 5.5 2497713779 Fix #598: the Free Transform widget
While the Free Transform tool (E) is active, a small box at the canvas's
top left holds Constrain over the tool's three modes: Free Transform,
Perspective Distort and Free Distort. Before, the modes were reachable
only by modifier keys or `tool.setOption`.

- The buttons set the tool's `constrain` and `mode` options through
  `tool.setOption`, as agents do. Constrain acts as Shift held and is
  greyed in the distort modes, where it does nothing. The modifier keys
  keep working while dragging.
- Theme tokens; Constrain shows the link icon, Free Transform the tool's
  own icon, the distort modes are drawn in code. Tooltips translated
  (cs, es, it, ja, pt-br, ru, zh-hans, zh-hant).
- `ui.inspect` reports where it shows (`freeTransformWidget`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:31:43 +07:00
BFlat e037064b2d Merge pull request: land #616 (fix #597)
Land #616 (fix #597: a browser-copied image pastes as the picture)
2026-10-09 05:31:24 +07:00
bflatastic 564a3ae662 Merge #616: Fix #597: an image copied in a browser pastes as the picture, not its address
# Conflicts:
#	ROADMAP.md
2026-10-09 05:26:43 +07:00
BFlat 106abe639f Merge pull request: land #612, #610, #611
Land #612 (fix #593), #610 (Shaper groups), #611 (per-paragraph attributes)
2026-10-09 05:25:17 +07:00
bflatasticandClaude Opus 5.5 54eed6ec4c Fix #597: an image copied in a browser pastes as the picture, not its address
A browser's Copy Image puts the picture on the clipboard with the image's
address as text (and HTML). Paste takes text before bitmaps, as word
processors offer a picture of copied text, so it pasted the address.

- `clipboard::is_address`: text that is only an address (http, https,
  file, ftp, data or blob).
- Desktop paste (Windows, macOS, Linux): such text gives way to the
  clipboard's bitmap when it has one. An address alone pastes as text, as
  before; nothing is fetched.
- Web: a paste whose text is only an address takes its picture file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:22:09 +07:00
dotCooCoo 67af21c6db Contributors: add dotCooCoo (Robert Lee) 2026-10-08 15:18:57 -07:00
dotCooCoo e0b1f55250 Swatches: swatch exchange (.ase) files open as swatch libraries
File › Open, Other Library… and the User Defined library folder read
.ase files. Colors keep their model, global and spot colors stay global
and spot, and color groups stay groups. The library readers take a
file's bytes. On the web, File › Open passes a swatch library file to
swatch.library.load as dataBase64 instead of as lossy UTF-8 text.

Swatch and group names in a library are now unique, in .gpl files too.
Add to Swatches puts each color group of a library into one document
group when the document already has a swatch with the group's name.
The library fuzz helpers fail when a command panics.
2026-10-08 15:18:57 -07:00
bflatastic 8d5b52965c Merge #611: M7: per-paragraph paragraph attributes 2026-10-09 05:13:59 +07:00
bflatastic 33f38cef08 Merge #610: Fix Shaper recognition and add editable Shaper groups
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-09 05:13:59 +07:00
bflatastic 8a18a74daf Merge #612: Fix #593: anchors and handle ends are easier to grab than the segments through them
# Conflicts:
#	ROADMAP.md
2026-10-09 05:13:43 +07:00
bflatasticandClaude Opus 5.5 1a1119b1d4 Tests: Arabic joining tests pick an OpenType (GSUB) font
macOS's Geeza Pro is an AAT font: its morx shaping takes no context from
outside the run, so different_style_arabic_boundary_receives_joining_context
failed on Macs (reported in #610). The tests now use the first candidate
with a GSUB table and skip when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:13:15 +07:00
BFlat 79a67e2642 Merge pull request: land #608 (fix #592)
Land #608 (fix #592: Linux file dialogs no longer stop the window answering)
2026-10-09 05:11:15 +07:00
bflatasticandClaude Opus 5.5 a0d5d72d90 Fix #593: anchors and handle ends are easier to grab than the segments through them
An anchor was picked within the selection tolerance (3 px), the same reach
as its segments: a press a little off the anchor's 5 px square took the
curve instead.

- `ToolContext::point_tol`: anchors and direction handle ends are picked
  within the selection tolerance, but at least 2 px past the drawn point
  (4.5 px at the default Size 3, more with a larger Size). Anchors are
  tested before segments, so where both are within reach the anchor wins.
- Direct Selection (presses, Highlight anchors on mouse over, blend spine
  points and handles), the Anchor Point, Delete Anchor Point and Scissors
  tools, and the Pen (its Alt conversions; its close/continue reach stays
  5 px unless the points are drawn larger) use it. Segments, objects and
  frame edges keep the selection tolerance.
- Of the topmost path with anchors in reach, the nearest anchor is picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:09:31 +07:00
haven f7761bf5bc M7: per-paragraph paragraph attributes
Each paragraph has its own alignment, indents, spacing, hyphenation, tab
stops and paragraph style (TextObject.paras; para stays paragraph 0's, so
old files and readers work). Layout, editing (split/merge), threads, the
Paragraph/Tabs/Paragraph Styles panels, ranged text.setStyle/setFormat/
tabs/paraStyle.apply, SVG export/import and the fuzzers follow.
2026-10-08 15:07:13 -07:00
bflatastic d9a2cfe7f9 Merge #608: Fix #592: file dialogs on Linux no longer stop the window answering
# Conflicts:
#	ROADMAP.md
2026-10-09 05:03:38 +07:00
David Whiting dc6f138a72 M2.9: translate Shaper commands in Russian 2026-10-08 23:03:34 +01:00
BFlat 6def232852 Merge pull request: land #606 (area type fit)
Land #606: area type Auto Size and Shrink Text to Fit
2026-10-09 05:03:02 +07:00
bflatasticandClaude Opus 5.5 0df8231334 Tests: format round trips find their objects by content, not by count
#606 adds a second area type (Shrink Text to Fit) to the rich fixture: the
vertical-alignment test now picks the centred area type by its text and
strips verticalAlign from every area for its old-file case, and the
composer test checks the Single-line text and the rest at the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:00:01 +07:00
bflatasticandClaude Opus 5.5 37d4477362 Fix #592: file dialogs on Linux no longer stop the window answering
VectorCraft asks for a path where it needs one and goes on with it at
once, so its file dialogs ran on the UI thread. Windows and macOS dialogs
run the window's events meanwhile; on Linux nothing answered the Wayland
compositor's pings while a dialog was open, and Hyprland offered to kill
the window as not responding.

- ui-egui `picks`: every file dialog (open, save, folder, place) goes
  through one place. With the host's `start_pick` (desktop Linux) the
  dialog shows on a thread of its own; what asked (the command, the
  dialog's OK, a panel's or dialog's button) gives up for now and runs
  again with the picked path when the dialog answers. Another dialog
  waits for the open one.
- The desktop app passes its window as the dialogs' parent on Linux (the
  portal can then associate its window, "Unhandled parent window type"
  before) and Windows; macOS shows them as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:59:05 +07:00
David Whiting 7e82dbc05b M2.9: add editable Shaper groups and shape orientation steps 2026-10-08 22:52:23 +01:00
David Whiting cfe8eaafaf M2.9: recognize uneven Shaper squares independently of stroke start 2026-10-08 22:52:23 +01:00
bflatastic 611deeda46 Merge #606: M7: area type auto size and shrink text to fit 2026-10-09 04:52:14 +07:00
BFlat c3b73f0146 Merge pull request: land #603 (part of #372)
Land #603 (part of #372: .ai layers and sublayers as PDF layers, hidden ones kept)
2026-10-09 04:51:50 +07:00
haven 674aef95b0 M7: area type auto size and shrink text to fit
Area Type Options gain a fit: none, autoHeight (Illustrator's Auto Size:
the frame's height follows the text after every edit, resolved in
refresh_bounds inside the edit so it shares the edit's undo step) and
shrinkText {minPercent} (beyond Illustrator: overflowing text scales its
sizes, leading and baseline shifts down at layout time by the largest
factor, in 0.1 pt steps of the first run, that fits). The Auto Size New
Area Type preference now applies. text.areaOptions and document.inspect
report overflow and the shrink factor; the Area Type Options dialog
offers fit and first baseline as dropdowns.
2026-10-08 14:48:25 -07:00
bflatastic ef99ab8d1c Merge #603: Part of #372: .ai files write layers and sublayers as PDF layers, hidden ones kept
# Conflicts:
#	ROADMAP.md
2026-10-09 04:46:09 +07:00
BFlat a89f914119 Merge pull request: land #600, #601
Land #600 (area type vertical alignment) and #601 (RTL shaping across runs)
2026-10-09 04:45:23 +07:00
bflatasticandClaude Opus 5.5 50bb36ebc2 Part of #372: .ai files write layers and sublayers as PDF layers, hidden ones kept
A `.ai` file's PDF part is what apps that don't read VectorCraft's native
document open. It had no PDF layers: hidden layers and sublayers were left
out of it and everything else was flat page content.

- `.ai` files (Save As, exports, the CLI) write PDF layers unless
  `createLayers` is false (a PDF standard still decides for itself).
- createLayers writes sublayers too, as optional content groups listed
  under their layer's in `/Order`, hidden ones off with their art.
- A layer's form gets a box round the whole page instead of its art's
  bounds, which readers made a clipping group of, gathering a layer's art
  and its sublayers' in one group.
- PDF import: art inside a group, clip or form that dissolves into its
  parent keeps the layer it was drawn in, so sublayers nested in their
  layer's content import as sublayers.
- Save PDF: "Create PDF layers from layers and sublayers" (ja, es, it,
  pt-br, ru, zh-hans, zh-hant).

Hidden objects (not layers) still aren't in the PDF part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:40:30 +07:00
bflatastic 4e774b8b61 Merge #601: Fix RTL shaping across text runs 2026-10-09 04:39:56 +07:00
bflatasticandClaude Opus 5.5 017c846889 Tests: the top-to-top em box check needs the craft-fonts CJK faces
Without craft-fonts the CJK text falls back to a system font with its own
em box (Windows: 34.0 instead of 35.2 at 40 pt); the relative alignment
checks hold either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:39:56 +07:00
bflatasticandClaude Opus 5.5 e12ed1608e Tests: the composer round trip counts #600's area type in the rich fixture
#587 and #600 landed together: the fixture's new area type (Every-line,
the default) is a second text object the composer test now sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:36:06 +07:00
Emad Fussi 9e6c115481 Fix RTL shaping across text runs 2026-10-09 00:34:00 +03:00
bflatastic c7b2bba90e Merge #600: M7: area type vertical alignment 2026-10-09 04:31:10 +07:00
haven d2bb95daab M7: area type vertical alignment
Area Type Options > Align: Top / Center / Bottom / Justify (AreaOptions.verticalAlign,
LayoutOptions.vertical_align). Each row/column cell is aligned on its own; rectangular
cells shift their lines, other frames and text wrap reflow until settled without
losing text. Justify shares the leftover space equally between line gaps. Area Type Options
shows Align and First Baseline as dropdowns (the parameter dialog's choices table).
2026-10-08 14:28:02 -07:00
BFlat 8d0cede84a Merge pull request: land #594 (fix #579)
Land #594 (fix #579: fonts from font services such as Adobe Fonts on Windows)
2026-10-09 04:26:21 +07:00
bflatastic 030d407f61 Merge #594: Fix #579: fonts activated by font services such as Adobe Fonts are listed on Windows
# Conflicts:
#	ROADMAP.md
2026-10-09 04:18:54 +07:00
BFlat 465ec07445 Merge pull request: land #591 (fix #578)
Land #591 (fix #578: no flicker dragging the Color spectrum near white)
2026-10-09 04:17:20 +07:00
bflatasticandClaude Opus 5.5 5854346053 Fix #579: fonts activated by font services such as Adobe Fonts are listed on Windows
Font services load their fonts with GDI at run time, from files in their
own folders, without installing them: neither the font folders nor the
registry list them, so the font scan never saw them. DirectWrite's system
font collection holds them since Windows 10 1607.

- vectorcraft-text: `set_platform_font_files` lets the app name font files
  the platform knows; each scan and each check for installed fonts asks
  again and reads those outside the font folders. A file named by itself
  is read whatever its extension (font services keep fonts in files
  without one).
- The desktop app lists the local files of DirectWrite's system font
  collection on Windows (`system_fonts.rs`, through the windows crate
  already in the build), read in place.
- The check for fonts installed while the app was in the background runs
  on a thread: DirectWrite's check for updates takes about 50 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:16:14 +07:00
bflatastic f4b4312cc9 Merge #591: Fix #578: a drag on the Color panel's spectrum no longer flickers near white
# Conflicts:
#	ROADMAP.md
2026-10-09 04:13:07 +07:00
BFlat 0869f51360 Merge pull request #587 from Haven-King/feat/text-engine/text-composer-ragged
M7: Every-line Composer for ragged text
2026-10-09 04:12:08 +07:00
haven d2aad22dac M7: Every-line Composer for ragged text
Illustrator's Every-line Composer balances the breaks of the whole
paragraph for left/centre/right aligned text too, not only justified
text. VectorCraft only composed justified area text; ragged text was
always broken greedily.

- Model: ParaStyle.composer (vectorcraft_doc::Composer, re-exported by
  vectorcraft-text), Every-line by default like Illustrator, saved only
  when Single-line. layout() reads it from the object;
  LayoutOptions.composer is now an Option override for tests.
- Composer: the justified gate is gone. Ragged lines never shrink (a
  line that doesn't fit ends the node), badness is 100*r^3 with r = the
  line's leftover space over a rag zone of 1/6 of the width (finite for
  lines without spaces); the last line is free unless shorter than 1/3
  of the width (measured in zones of 1/3), so a lone short word on the
  last line pulls a word down. Hyphens cost 25 in ragged text (50 in
  justified). Knuth-Plass fitness classes with adjacency demerits
  (justified 10000, ragged 1000). Lines past the last width change
  share one node key, active nodes are capped (4096) and paragraphs
  over 200k glyphs break single-line, so work stays bounded; ragged
  tries a tolerance of one rag zone, then any fitting breaks, then
  falls back to greedy.
- text.setFormat {composer: "singleLine"|"everyLine"}; the Paragraph
  panel menu's composer items work with a check mark (Reset Panel goes
  back to Every-line).
- Tests: ragged Every-line moves "or" down in "Destroy all creatures
  with toughness X or less." where greedy leaves "less." alone; the rag
  of long paragraphs is no worse than greedy (sum of squared leftover
  and of neighbour differences); Single-line is greedy; the 10k-char
  layout budget covers ragged text; command test with undo; native
  format round trip (rich_session's text is now Single-line).
  No existing test needed changing: the justified tests and
  hyphenated_layout_adds_hyphens (ragged, now Every-line) still pass.

Auto alignment (start of the paragraph direction) is ragged; a wrapped
line's Mojikumi flush opening bracket gives its half em back to the
composer, as in the line loop; tests cover Auto in both directions.

Layout of 10k chars, release: justified Every-line 5.6 ms, ragged
Every-line 5.8 ms, ragged Single-line 4.8 ms.
2026-10-08 14:03:33 -07:00
bflatasticandClaude Opus 5.5 2e64786e8c Fix #578: a drag on the Color panel's spectrum no longer flickers near white
The Color panel shows the out-of-gamut and out-of-web warnings under its
proxy only when the colour needs them, so the panel grew and shrank with
the colour. Dragging on the spectrum's pale top edge, a colour out of
gamut added a warning, the spectrum moved down under the pointer, the
pointer then picked white (above the spectrum), the warning went away,
the spectrum moved back up: the colour flipped every frame.

The panel now keeps room for the warnings its mode can show, so its
height no longer depends on the colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:02:44 +07:00
BFlat 367117ba26 Merge pull request: land #583, #584 (fix #577)
Land #583, #584 (fix #577) with a ligature-threshold follow-up
2026-10-09 04:02:24 +07:00
bflatasticandClaude Opus 5.5 5a09d1aef8 Type: ligatures drop past -20 or +55 tracking, as Adobe's composers do
#583 kept ligatures within +-50/1000 em. Adobe's composers are described
as dropping them past about -8% and +22% of the word space (John Hudson,
www-style, 2012-07): about -20 and +55 for a typical 250/1000 em space.
Condensed type shows a fixed ligature sooner than expanded type does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:56:10 +07:00
bflatastic e1d560a727 Merge #584: Fix #577: the gradient stop popover stays open after the click that opens it
# Conflicts:
#	ROADMAP.md
2026-10-09 03:55:34 +07:00
bflatastic 07654ff45d Merge #583: M7: keep ligatures under small tracking 2026-10-09 03:55:19 +07:00
BFlat 53297259d2 Merge pull request: land #581 (Russian translation)
Land #581: Russian (ru) interface translation
2026-10-09 03:54:27 +07:00
bflatasticandClaude Opus 5.5 b771a74361 Russian: H is В (высота), not Ш
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:52:15 +07:00
bflatasticandClaude Opus 5.5 ba93f62bcb Fix #577: the gradient stop popover stays open after the click that opens it
The Gradient panel's colour chip opens the stop popover on a click. The
popover closes on a click elsewhere, and the opening click itself, which
is released in the frame the popover first draws and lies outside it,
counted as one: the popover closed at once (a double-click got through,
as the popover lets the double-click that opens it from the canvas pass).

The popover now records the frame it first drew in and only a click in a
later frame closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:48:27 +07:00
bflatastic 04827da657 Merge #581: Add Russian (ru) interface translation 2026-10-09 03:48:19 +07:00
BFlat d066e0ddc0 Merge pull request: land #580 (fix #522)
Land #580 (fix #522: SVG round trips keep hidden objects and data-* attributes)
2026-10-09 03:47:30 +07:00
MaksPV 89b10cce52 Add Russian (ru) interface translation
Add ru.tsv with the full interface coverage: every menu string, every
tl! literal, the plurals (one/few/many) and the status/error messages
(@msg). Registered as complete_menus with the Russian plural rule;
every ru-* locale resolves to it. Keeps the product, workspace and
perspective preset names in English like the other complete catalogs.

Tests: russian_reads_as_russian, russian_plurals_have_three_forms,
russian_glyphs_are_available_without_system_fonts; ru added to
KEEPS_MENU_NAMES and COMPLETE_MESSAGES.

Clean-room: written from the meaning of the English labels in the
vocabulary Russian-speaking users of vector illustration software know
(Слой, Образцы, Обработка контуров, Монтажная область, Обводка,
Заливка), matching the Russian catalog of the sibling app PhotoCraft.
No other application's localisation was copied.
2026-10-08 23:44:45 +03:00
bflatastic c7094320b3 Merge #580: Fix #522: SVG round trips keep hidden objects and data-* attributes
# Conflicts:
#	ROADMAP.md
2026-10-09 03:41:22 +07:00
bflatasticandClaude Opus 5.5 2a35c4a82f Fix #522: SVG round trips keep hidden objects and data-* attributes
An SVG used as an animation rig lost its hidden alternates and their
rotation pivots: the exporter left hidden objects out (only hidden layers
could be kept), and the importer dropped data-* attributes.

- SVG export: hiddenLayers (on by default for document.save, as before)
  now keeps hidden objects too, each written display="none" on its own
  element (path, image, text or group). Exports still leave them out
  unless asked; the CLI's convert asks, as a conversion keeps the
  document.
- An object's own data: ObjectAttributes.data, the data-* attributes in
  order (ours, data-name and data-vc-*, aside). The importer keeps them on
  the objects and layers they belong to (at most 64 per element), the
  exporter writes them back, the native format saves them (missing in
  older files), and object.setProps {data: {key: value|null}} sets or
  removes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:36:08 +07:00
BFlat 28846a98a0 Merge pull request: land #574 (fix #526)
Land #574 (fix #526: the exports and the CLI write .ai as Save As does)
2026-10-09 03:31:37 +07:00
bflatastic 4051a895d7 Merge #574: Fix #526: the exports and the CLI write .ai as Save As does
# Conflicts:
#	ROADMAP.md
2026-10-09 03:26:21 +07:00
bflatasticandClaude Opus 5.5 1e2cb082ba Fix #526: the exports and the CLI write .ai as Save As does
file.saveAs and document.save wrote a PDF-compatible .ai, but
document.export (and with it the CLI's run --export x.ai and convert
in.svg out.ai, and MCP's export) refused it as a format that can only be
opened, and --help didn't list it as writable.

The .ai encoder is now shared: save.rs's ai_params and encode_ai (a PDF
of every artboard with the PDF options, carrying the native document,
blank pages with pdfCompatible: false) serve Save As and the exports
alike. The .ai format is writable, so document.formats, --help and the
MCP export tool list it; an export leaves the document's own path alone.
Export As and Export for Screens keep leaving .ai to Save As.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:18:07 +07:00
BFlat 207bd09635 Merge pull request #572 from storytold/fix/issue-569
Fix #569: Shape Builder merges leave no stray edges in their outlines
2026-10-09 03:12:05 +07:00
bflatasticandClaude Opus 5.5 2023a47188 Fix #569: Shape Builder merges leave no stray edges in their outlines
Merging regions united the faces' outlines with a boolean union. Each
face had been refit on its own, so two faces' shared edge could come out
a hair apart: the union then kept the edge between them, or a spur of
it, and a stroked merged shape (unfilled art) showed a stray line inside.
Subtracting the merged shape from the others left slivers along it too.

pathops::FaceMerger merges faces from the planar arrangement itself,
made once per merge: their contours share their edges exactly, so the
edges between merged faces vanish. The Shape Builder builds the merged
shape that way, and what is left of each shape from its faces not merged
(rather than subtracting the merged shape from it). Selections with open
paths keep the old union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:04:48 +07:00
BFlat 85319c972c Merge pull request #571 from mo0kid/fix/macos-native-menu-dismissal
Fix macOS native menu dismissal
2026-10-09 03:00:47 +07:00
haven 0d68c1283a M7: keep ligatures under small tracking
Only real letterspacing (|tracking| > LIGATURE_TRACKING_LIMIT = 50/1000 em)
drops standard ligatures now, and an explicit liga/clig in the character's
features always wins. The OpenType panel's hint follows the same rule.
2026-10-08 12:55:39 -07:00
David Whiting 94ecbec9fc M14.8: opt in to public GitHub profile-name credit 2026-10-08 20:50:54 +01:00
David Whiting cb61bdf61f M14.8: dismiss macOS native menus after selection or outside input 2026-10-08 20:50:54 +01:00
David Whiting d1d78e61da M0.CI: restore checks without optional fonts 2026-10-08 20:50:54 +01:00
bflatasticandClaude Opus 5.5 1d013959cb Fix #530: Edit Artboards adds, deletes and leaves artboards from Properties
The Properties panel's Edit Artboards turned on the Artboard tool but the
panel went on showing the document's sections, with no way there to add
an artboard (only dragging on the canvas, which the hint bar names, or
the Artboards panel's button). While the Artboard tool is on, the panel
now shows the active artboard: its name and X, Y, W and H (editable,
artboard.setProps), New Artboard (which becomes the active one), Delete
Artboard (with more than one), and Exit back to the Selection tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:29:26 +07:00
BFlat c26c4a2060 Merge pull request #564 from Haven-King/feat/vectorcraft-placed-documents
Feat/vectorcraft placed documents
2026-10-09 02:26:56 +07:00
haven 5e7af3f83c Placed documents: Place's Link tooltip, Break Link in the Links panel 2026-10-08 12:20:59 -07:00
haven 2418921760 Placed documents: on screen, cached bitmaps made in the background 2026-10-08 12:20:51 -07:00
haven e04b94938b Placed documents: Links commands check, update, relink and break their links 2026-10-08 12:20:51 -07:00
haven afc45cdeb8 Placed documents: File › Place links a VectorCraft document by default; output reads its file 2026-10-08 12:20:51 -07:00
haven 6d56195eda Placed documents: the object, drawn exactly and kept as art by every exporter 2026-10-08 12:20:51 -07:00
haven 02072c310a Format v4: large data after the JSON 2026-10-08 12:20:51 -07:00
haven 92817d6ca1 Widgets: check_tip, a checkbox whose tooltip shows over its box and label 2026-10-08 12:20:51 -07:00
BFlat 857273001b Merge pull request: land #565 (fix #541)
Land #565 (fix #541: a click on a selected object makes it the key object)
2026-10-09 02:16:52 +07:00
callmeBallochandClaude Opus 5.5 2e55ce6d5b AF.3: open .afphoto by content without listing it for Finder
The macOS bundle must declare every File › Open extension, and the
packaging of a vector app must not mention raster-editor files. Affinity
Photo documents still open and place: the reader recognizes them by
their content, and a test checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 19:13:50 +00:00
bflatastic 48f07958a9 Merge #565: Fix #541: a click on a selected object makes it the key object
# Conflicts:
#	ROADMAP.md
2026-10-09 02:11:01 +07:00
BFlat c6bea61ffc Merge pull request: land #562 (fix #555)
Land #562 (fix #555: the font menu scrolls only its list)
2026-10-09 02:09:49 +07:00
bflatasticandClaude Opus 5.5 f31d6874aa Fix #541: a click on a selected object makes it the key object
With the Selection tool, a click (no drag, no modifier) on one object of a
selection of several makes it the key object (select.key); a click on the
key again lets it go. The key object is drawn with a thicker outline,
Align aligns to it (the Align panel shows Align to Key Object while there
is a key, and choosing Selection or Artboard lets it go), and
object.align without `to` aligns to the key when there is one, so the
Control bar's and the Properties panel's Align buttons do too. With a
spacing, Distribute Spacing keeps the key object still and spaces the
others from it, and it reports too few objects instead of indexing an
empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:08:56 +07:00
bflatastic c44572d272 Merge #562: Fix #555: the font menu scrolls only its list
# Conflicts:
#	ROADMAP.md
2026-10-09 02:05:49 +07:00
bflatasticandClaude Opus 5.5 1b43d8b8f9 Fix #555: the font menu scrolls only its list
egui's combo box puts its own scroll area around a combo's list, so the
font menu, which scrolls its families itself, had two scrollbars: once
the families reached their end the wheel scrolled the outer one, taking
the search field and the filters out of view.

The font menu (widgets::combo with searchable) now opens its list in a
popup of its own, drawn like the combo box, without that outer scroll
area: below the button, or above it when there is more room there. The
families list fills the popup's room under the search field and filters,
which stay at the top while the list scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:54:11 +07:00
callmeBalloch 2b4d8f4074 Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2
# Conflicts:
#	README.md
#	ROADMAP.md
#	apps/vectorcraft/src/open_documents.rs
2026-10-08 18:45:39 +00:00
callmeBallochandClaude Opus 5.5 297a01b6aa AF.3: native Affinity import with artboards, vectors, text and images
Open and Place read Affinity documents natively (.af from Affinity 3,
.afdesign/.afphoto/.afpub from Affinity 1 and 2): layers and sublayers,
groups, artboards with their names and backgrounds, Publisher pages side
by side, curves with live corners, parametric shapes, compound shapes,
clipping by nesting, vector and pixel masks, solid and gradient fills,
strokes, fill layers, artistic and frame text, placed images (their
original JPEG/PNG), pixel layers, embedded documents as their cached
picture, opacity, visibility, locking and blend modes, at the document's
resolution in points. Unsupported content (effects, adjustments, brushes,
master pages, special shapes, Affinity-only blend modes...) is listed in
the import warning, one line per kind. A file whose native data can't be
read opens as its embedded preview with the reason, and that preview
cannot be placed or used as a template or library. Save never writes back
over the Affinity file and there is no Affinity export.

The document semantics were fitted to the thumbnails Affinity embeds in
176 public documents. `cargo xtask corpus --affinity` fetches 22 public
CC0/MIT/Apache-2.0 documents at pinned commits, sha256-verified; the new
engine test renders each and compares it with Affinity's own thumbnail
(0-4.4 of 255 mean difference, per-file ceilings). import_fuzz mutates a
synthetic native document's stream and container through open, render
and export; the fuzz crate gains a stream target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 17:58:11 +00:00
callmeBallochandClaude Opus 5.5 1c47480a09 AF.2: bounded Affinity archive and object-stream reader
Read the native container beyond its preview: the chain of allocation
tables (#FAT, #FT2, #FT3, #FT4) with newest-revision selection, CRC-checked
extraction of stored, zlib and zstd entries with their byte predictors,
and the self-describing tagged object stream in doc.dat as a generic tree
of shared, inline and positional objects.

Every declared size is checked against per-entry and per-import budgets
before allocation; the zstd window is capped at 64 MiB; table chains are
cycle-checked; arrays are checked against the remaining bytes; object
nesting stops at 384 levels.

The header now accepts container versions 8 to 12 and the flag values 4
and 8 that real Affinity 3 files carry (the previous check rejected the
public CC0 Affinity 3 documents); the thumbnail record is the same in all
those versions. Checked against 176 public documents saved by Affinity 1.x
to 3.x: every doc.dat decompresses with a matching CRC and parses with no
bytes left over.

A test-only `synth` feature builds synthetic containers for unit tests
and fuzz seeds; it is not an Affinity exporter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 17:09:56 +00:00
callmeBalloch 3e782b22a9 Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2
# Conflicts:
#	ROADMAP.md
2026-10-08 16:08:36 +00:00
callmeBalloch 3c7bdfe6a3 AF.1: import bounded Affinity previews with explicit fidelity warnings
Read only the indexed PNG thumbnail observed in version-12 Affinity containers. Bound and validate the PNG before decoding, warn about omitted native content, clear the source save target, and reject auxiliary imports that cannot display that warning. Add malformed-input, lifecycle, property and optional native-oracle tests plus a fuzz target. Editable native layers and Affinity export remain unsupported.
2026-10-08 17:19:18 +02:00
callmeBalloch c13e4133e0 CI.1: make baseline checks portable across toolchains and hosts 2026-10-08 17:19:04 +02:00
808 changed files with 172475 additions and 7925 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Affinity 3 corpus
on:
push:
branches: [main]
paths: ["crates/**", "xtask/**", "Cargo.toml", "Cargo.lock", ".github/workflows/affinity-corpus.yml"]
pull_request:
paths: ["crates/**", "xtask/**", "Cargo.toml", "Cargo.lock", ".github/workflows/affinity-corpus.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: affinity-corpus-${{ github.ref }}
cancel-in-progress: true
jobs:
affinity3:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
AFFINITY_CORPUS_REQUIRED: "1"
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
toolchain: "1.95"
- name: Fetch pinned and checksum-verified corpus
run: cargo xtask corpus --affinity
- name: Parse every file and freeze actual Affinity 3 feature retention
run: cargo test -p vectorcraft-affinity --all-features -- --nocapture
- name: Check every artboard, native save, SVG, PDF and PSD exports
run: cargo test -p vectorcraft-engine --test affinity_artboards --test affinity_text --test affinity_corpus -- --nocapture
+10 -10
View File
@@ -29,16 +29,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -67,16 +67,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -97,7 +97,7 @@ jobs:
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
done
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: freebsd-package
path: dist/release/*
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@@ -46,7 +46,7 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
+35
View File
@@ -0,0 +1,35 @@
name: Desktop printing
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "apps/vectorcraft/Cargo.toml"
- "apps/vectorcraft/src/printing.rs"
- "apps/vectorcraft/src/printing/**"
- "crates/ui-egui/src/print.rs"
- "crates/ui-egui/src/tests_printps.rs"
- ".github/workflows/print.yml"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
windows:
runs-on: windows-2022
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: desktop-print
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
- run: cargo test --locked -p vectorcraft
- name: Check pending submission and queue refusal feedback
run: cargo test --locked -p vectorcraft-ui-egui tests_printps
+124 -42
View File
@@ -43,7 +43,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -71,18 +71,18 @@ jobs:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@@ -102,7 +102,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -130,18 +130,18 @@ jobs:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
@@ -150,7 +150,9 @@ jobs:
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
@@ -165,7 +167,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -189,13 +191,13 @@ jobs:
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Install build and packaging tools
run: |
@@ -206,8 +208,8 @@ jobs:
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@@ -234,7 +236,7 @@ jobs:
| grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -257,8 +259,8 @@ jobs:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -272,7 +274,7 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
@@ -290,22 +292,22 @@ jobs:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
if: inputs.version != ''
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package in a FreeBSD VM
# v1.5.9, pinned by commit.
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -326,7 +328,7 @@ jobs:
bash packaging/freebsd/package.sh
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd
path: dist/release/*
@@ -342,18 +344,18 @@ jobs:
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
@@ -369,17 +371,97 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
# The riscv64 variants of the linux job's -dev packages.
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
desktop-file-utils appstream
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the package
run: |
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
name: Draft GitHub Release
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
# drafts a release.
needs: [version, macos, windows, linux, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
@@ -390,7 +472,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
+8 -6
View File
@@ -36,22 +36,24 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
@@ -64,7 +66,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: vectorcraft-windows-arm64
path: dist
+15 -4
View File
@@ -1,11 +1,22 @@
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
name: Windows 7 x64 compatibility
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "apps/vectorcraft/Cargo.toml"
- "apps/vectorcraft/src/printing.rs"
- "apps/vectorcraft/src/printing/**"
- "crates/ui-egui/src/print.rs"
- "crates/ui-egui/src/tests_printps.rs"
- "packaging/windows/windows7.ps1"
- ".github/workflows/windows7.yml"
schedule:
- cron: "23 5 * * 1" # weekly
workflow_dispatch:
@@ -22,11 +33,11 @@ jobs:
runs-on: windows-2022
timeout-minutes: 90
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
with:
arch: x64
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: nightly-2026-10-01
components: rust-src
@@ -36,7 +47,7 @@ jobs:
- name: Build, check and test the portable package
shell: pwsh
run: ./packaging/windows/windows7.ps1 -Test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vectorcraft-windows7-x64-portable
path: dist/release/vectorcraft-windows7-x64-portable.zip
+10 -5
View File
@@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
## Start every session here
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
@@ -56,10 +56,15 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
## Roadmap and progress docs
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
- Keep the README's Status section in step with the ROADMAP headline.
## Contributor credits (About window)
+2
View File
@@ -83,6 +83,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/copy.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/dc-actions.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-al-bottom.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-al-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-al-hcenter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-al-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-al-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -98,6 +99,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | The Selection and Direct Selection arrows' fill, painted black or white under their outline |
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+10 -5
View File
@@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
## Start every session here
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
@@ -56,8 +56,13 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
## Roadmap and progress docs
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
- Keep the README's Status section in step with the ROADMAP headline.
Generated
+278 -27
View File
@@ -200,6 +200,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.6.1"
@@ -713,6 +719,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -790,6 +806,23 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -861,6 +894,19 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1066,6 +1112,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -1438,6 +1505,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1792,11 +1868,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locale_fallback"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
dependencies = [
"icu_locale_core",
"icu_locale_fallback_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_fallback_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
[[package]]
name = "icu_normalizer"
version = "2.3.0"
@@ -1846,6 +1943,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -1853,6 +1952,29 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"core_maths",
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1940,6 +2062,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2067,6 +2208,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "krilla"
version = "0.8.2"
@@ -2119,6 +2270,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2167,9 +2324,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linesweeper"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
dependencies = [
"arrayvec",
"kurbo 0.13.1",
@@ -2553,6 +2710,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -2731,6 +2897,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -3030,6 +3206,8 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -3460,6 +3638,15 @@ dependencies = [
"unicode-script",
]
[[package]]
name = "ruzstd"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
dependencies = [
"twox-hash",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -4002,6 +4189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -4123,6 +4311,12 @@ dependencies = [
"core_maths",
]
[[package]]
name = "twox-hash"
version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "type-map"
version = "0.5.1"
@@ -4296,11 +4490,12 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"arboard",
"block2 0.6.2",
"clipboard-win",
"craft-print",
"eframe",
"egui",
"image",
@@ -4308,19 +4503,39 @@ dependencies = [
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"serde_json",
"vectorcraft-engine",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wayland-client",
"wayland-protocols",
"webbrowser",
"windows",
"winit",
"winresource",
"winsafe",
]
[[package]]
name = "vectorcraft-affinity"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
"image",
"proptest",
"ruzstd",
]
[[package]]
name = "vectorcraft-brush"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4332,7 +4547,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4348,8 +4563,11 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"log",
"objc2-core-foundation",
"objc2-core-text",
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4360,11 +4578,14 @@ dependencies = [
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"windows",
"winsafe",
]
[[package]]
name = "vectorcraft-color"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4376,7 +4597,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4393,7 +4614,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4403,20 +4624,24 @@ dependencies = [
"vectorcraft-pathops",
"vectorcraft-plugins",
"vectorcraft-text",
"vectorcraft-three-d",
]
[[package]]
name = "vectorcraft-engine"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
"image",
"kurbo 0.13.1",
"log",
"proptest",
"ruzstd",
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-affinity",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
@@ -4439,17 +4664,20 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"ruzstd",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-testkit",
"vectorcraft-text",
@@ -4457,7 +4685,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"flate2",
"proptest",
@@ -4471,7 +4699,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4483,7 +4711,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"log",
@@ -4502,7 +4730,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4516,7 +4744,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4528,7 +4756,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"aes",
"flate2",
@@ -4557,7 +4785,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"serde",
"serde_json",
@@ -4571,7 +4799,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"flate2",
"gif",
@@ -4596,7 +4824,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4620,7 +4848,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4637,9 +4865,10 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
"kurbo 0.13.1",
"log",
"skrifa 0.47.0",
@@ -4647,15 +4876,24 @@ dependencies = [
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"unicode-segmentation",
"vectorcraft-doc",
"vectorcraft-geom",
"windows-registry",
"write-fonts",
]
[[package]]
name = "vectorcraft-three-d"
version = "0.10.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
]
[[package]]
name = "vectorcraft-tools"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4670,7 +4908,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4683,15 +4921,18 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"craft-ui",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
"serde",
"serde_json",
"toml",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4706,13 +4947,14 @@ dependencies = [
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-three-d",
"vectorcraft-tools",
"wat",
]
[[package]]
name = "vectorcraft-web"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -4721,6 +4963,7 @@ dependencies = [
"rfd",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -5695,6 +5938,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "winsafe"
version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
@@ -5815,7 +6064,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"serde_json",
]
@@ -5999,6 +6248,7 @@ dependencies = [
"displaydoc",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
@@ -6007,6 +6257,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+10 -3
View File
@@ -4,13 +4,15 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -20,6 +22,7 @@ vectorcraft-brush = { path = "crates/brush" }
vectorcraft-trace = { path = "crates/trace" }
vectorcraft-text = { path = "crates/text" }
vectorcraft-effects = { path = "crates/effects" }
vectorcraft-three-d = { path = "crates/three-d" }
vectorcraft-render = { path = "crates/render" }
vectorcraft-svg = { path = "crates/svg" }
vectorcraft-pdf = { path = "crates/pdf" }
@@ -33,6 +36,7 @@ vectorcraft-cad = { path = "crates/cad" }
vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -42,6 +46,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
proptest = "1"
crc32fast = "1.5"
# tests: a TCP port bound without listening (connections to it are refused)
socket2 = "0.6"
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
@@ -52,14 +57,16 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
wat = "1.228"
# rendering / formats
vello_cpu = "0.2"
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
linesweeper = "0.5"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
+46 -18
View File
@@ -148,6 +148,9 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Quick start
```sh
git clone https://github.com/storytold/vectorcraft
cd vectorcraft
cargo run --release -p vectorcraft # desktop app
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft
cargo run --release -p vectorcraft -- --control 7979 # + JSON control channel
@@ -169,8 +172,14 @@ Graphics Processor** and restart. If a graphics processor can't show the window,
next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low`), or `WGPU_ADAPTER_NAME` to part of
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open;
to place artwork in the current document, copy and paste the file in a file manager. For file
drag-and-drop, set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart, or start
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
@@ -181,36 +190,55 @@ Register the MCP server with Claude Code:
claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp
```
To keep an agent to one project's files, give it folders to read and write (the same flags as PhotoCraft):
```sh
claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project
```
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
## Status
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
all of Illustrator): everyday
vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
speaks English, Japanese, Traditional and Simplified Chinese, Spanish and Italian (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
**What's missing:**
- 3D and Materials;
- the Photoshop-style raster effects (Effect Gallery);
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- Variables and scripting;
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- packaging for Windows and Linux.
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
**Where we're going:** next is the interaction-fidelity pass alongside the raster-effects package, then 3D and
advanced type, then hardening and packaging for 1.0. The prioritized list is in
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
[`docs/gaps.md`](docs/gaps.md).
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
the UI can be swapped without touching the engine.
+136 -226
View File
@@ -1,254 +1,164 @@
# VectorCraft Roadmap
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux and the web (WASM), and agents can drive it fully over a JSON control channel and MCP.
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
_Last updated: 2026-10-09 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into flLine truncated
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
## Where we are
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
| Dimension | Status |
|---|---|
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
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
control channel. This page is the summary; the evidence is in [`docs/target-app-parity.md`](docs/target-app-parity.md)
and the work list in [`docs/gaps.md`](docs/gaps.md).
## Honest assessment (2026-10-05)
**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance,
type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's
own `.ai` files open with a wrong layer structure in real cases and can't be saved back with Illustrator's
editing data, the interaction details have never been checked side by side, and some machines fail to start the
app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow
gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday
vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured
against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can
switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes.
## Headline numbers
**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs
(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is
the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine
since 2026-10-01.
### By dimension
| Dimension | Estimate | Evidence and what's missing |
| | Value | Kind |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **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 renders through DX12 before Vulkan (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; 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 Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); 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 |
| **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) |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
### Where we're lacking (in priority order)
**Readiness by audience** (all estimated; method in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work)):
Ordered by how much each gap blocks someone from switching. Sizes are one-agent hours from the parity table.
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. Do it side by side with Illustrator where the owner allows. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 35–48 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and
barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's merged-PR timestamps (e.g. 0.7–1.2 h per
Photoshop-style filter, ~0.8 h per medium feature); see [calibration](docs/target-app-parity.md#calibration).
The installed Illustrator was **not** inspected: the clean-room rule in [`AGENTS.md`](AGENTS.md) forbids it, so
everything is measured from our code against Illustrator's public documentation.
### Where we're going
## By dimension
- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad,
so they split well across agents, and they move the "can't tell the difference" number most.
- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding.
- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any
budget it misses before 1.0.
- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web.
| Dimension | % | One agent (h) | Doc |
|---|---:|---:|---|
| Features (depth) | 75% | 220–360 | [target-app-parity.md](docs/target-app-parity.md) |
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
| Ecosystem and plug-ins | 15% | 30–60 | [gaps.md › G17](docs/gaps.md#g17-ecosystem-third-party-plug-ins) |
| AI features (generative) | 0% | 30–60 + owner decision | [gaps.md › G18](docs/gaps.md#g18-generative-ai-features) |
| Agent automation | beyond Illustrator | — | [mcp.md](docs/mcp.md) |
**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding.
When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest:
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
The dimension hours overlap (feature rows hold some UI, format and AI work), so they don't add up to the total.
## Shipped so far
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Automation:**
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- JSON-lines control channel with real egui pointer and keyboard injection.
- 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.
- 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).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish and Italian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 30 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Spacing Guides, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and 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).
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. 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.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) 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, Spanish (neutral international Spanish, for every `es-*` locale) and Italian (every `it-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish and Italian 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, Spanish and Italian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **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; 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 or an image over 64 megapixels is refused with advice rather than running out of memory, 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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **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).
- **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).
- 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); 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; 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).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- **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.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Features
## Milestones and estimates
| Area | % | One agent (h) |
|---|---:|---:|
| Selection, transform & align | 85% | 4–6 |
| Drawing tools | 85% | 8–13 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 80% | 4–8 |
| Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 |
| Strokes, brushes, width profiles | 88% | 8–15 |
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
| Live vector effects | 85% | 6–10 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 |
| 3D and Materials | ~25% | 30–55 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
| Image Trace, graphs, image tools | 75% | 5–8 |
| Layers, artboards, document setup | 78% | 5–8 |
| View & navigation | 70% | 9–14 |
| Guides, grids, smart guides, snapping | 80% | 4–7 |
| File formats — [file-format-parity.md](docs/file-format-parity.md) | 75% | 10–20 |
| Export for Screens, Asset Export, slices | 85% | 2–4 |
| Print, colour management, separations | 80% | 2–4 |
| Automation (Actions, Variables, scripting) | 60% | 8–14 |
| UI chrome (panels, Properties, preferences) | 80% | 13–20 |
| Libraries, Links, Package | 82% | 2–3 |
| Generative AI | 0% | 30–60 |
| # | Milestone | Status | Est. remaining, one agent (h) |
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (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 (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper, the remaining Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; 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; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, 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, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| 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 (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator 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 (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker 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) | 🟡 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 and menus that scroll when longer than the window done; 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) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
Weights, evidence and the missing items per area: [target-app-parity.md](docs/target-app-parity.md#feature-areas).
## Parity estimate
## Languages
_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area
is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining
time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed
about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds
and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type)
are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review
found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took
about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high.
| Language | Code | Status | Translated |
|---|---|---|---:|
| English | `en` | full | 100% |
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
| Spanish | `es` | full | 100% |
| Hindi | `hi` | none | 0% |
| Arabic | `ar` | none (no right-to-left interface) | 0% |
| French | `fr` | full | 100% |
| Portuguese (Brazil) | `pt-br` | partial | 68% |
| Indonesian | `id` | none | 0% |
| Japanese | `ja` | full | 100% |
| German | `de` | full | 98% |
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | full | 100% |
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 79% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 84% | global/video rulers, Spacing Guides, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506) | 2–4 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **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) | | | | 50–80 |
| **Full parity** | | **~56%** | | **~320–500** |
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
**~80–145 h** to full parity.
## Upcoming
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except Touch Type; 51 panel modules; Illustrator-style live
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
1. **Interaction-fidelity pass** (G1), tool by tool from [ui-parity.md](docs/ui-parity.md): 60–90 h.
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
3. **Launch and stability on real machines** (G10): 20–35 h.
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
5. **Raster effects** (G2) alongside: 28 filters and the Effect Gallery, 23–39 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
## Progress log
## Where we aim to be better than Illustrator
- **Speed:** off-thread multithreaded rendering, instant startup, a responsive UI on huge files.
- **Robustness:** exact curve booleans (no "cannot perform operation"), property-tested undo/redo and file round trips.
- **Openness:** a documented native format, first-class SVG, and the same app in the browser.
- **Automation:** every command, gesture, dialog and widget is drivable by agents (JSON channel + MCP), with headless batch mode.
| Date | What landed |
|---|---|
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
| 2026-10-07 | v0.4.0; issue fixes; Layers panel rework |
| 2026-10-06 | M8 transform and distort fidelity pass (M8.1–M8.20) |
| 2026-10-05 | M4 files done (M4.14–M4.98); M3 paint and appearance done (92 tasks over two days) |
| 2026-10-02 | First weighted parity estimate (22 areas); look and feel measured against public screenshots |
| 2026-10-01 | Renamed from DrawCraft to VectorCraft |
| 2026-09-30 | Repository history begins |
## How to update this file
After each milestone task lands, update the status column and remaining estimates, and move items into "Shipped so far". Keep estimates honest: re-derive them from what remains, never from wishful velocity.
The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
| 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha |
| 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` |
| 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list |
| 2026-10-02 | major | First 22-area weighted parity estimate |
+19
View File
@@ -22,7 +22,26 @@ vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
vectorcraft-mcp = { workspace = true }
vectorcraft-render = { workspace = true }
vectorcraft-text = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# `perf` on Windows (src/perf.rs) reads how busy the processors are (GetSystemTimes) and this
# process's working set (GetProcessMemoryInfo) through winsafe's safe wrappers over Win32 (MIT),
# which the desktop app's src/gpu.rs also uses.
[target.'cfg(windows)'.dependencies]
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
[target.'cfg(target_os = "macos")'.dependencies]
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
+110 -23
View File
@@ -1,17 +1,20 @@
//! `vectorcraft-cli`: VectorCraft from the command line.
//!
//! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
//! vectorcraft-cli perf [--paths 50000] [--deep]
//! ```
#![forbid(unsafe_code)]
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
#![deny(unsafe_code)]
use std::io::Write;
use std::path::Path;
use std::process::ExitCode;
/// `println!` / `print!` that end the program quietly when stdout is closed
@@ -34,9 +37,21 @@ macro_rules! out {
}};
}
/// The desktop app's CoreText font lister, shared: exports and MCP find the same fonts.
#[cfg(target_os = "macos")]
#[path = "../../vectorcraft/src/mac_fonts.rs"]
mod mac_fonts;
mod perf;
/// The desktop app's settings folder, shared: exports and MCP read the same Fonts folder.
#[path = "../../vectorcraft/src/prefs_dir.rs"]
mod prefs_dir;
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[path = "../../vectorcraft/src/system_fonts.rs"]
mod system_fonts;
use serde_json::{Value, json};
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
@@ -53,9 +68,13 @@ const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
USAGE:
vectorcraft-cli mcp [--connect ADDR | --headless]
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
(vectorcraft --control 7979), falling back to a headless in-process session.
--automation-read-root / --automation-write-root confine the files the agent's commands read
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
DIR, links followed; a root left out grants none of its access. They imply --headless: a
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
@@ -65,11 +84,16 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
Live effects are kept; --outline-text writes SVG text as paths.
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
precision (1–7, default 3). OUT may not be IN when reading
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
draws differently from its PDF part (which can be older), as long as it can be read.
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -80,9 +104,11 @@ USAGE:
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded.
vectorcraft-cli perf [--paths N]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
vectorcraft-cli perf [--paths N] [--deep]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
budget is exceeded, unless the report says the machine is busy. --deep also times work with
no budget yet and prints memory use (the working set on Windows).
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
@@ -92,6 +118,12 @@ fn usage() -> String {
}
fn main() -> ExitCode {
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
prefs_dir::install_fonts();
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("mcp") => mcp(&args[1..]),
@@ -123,18 +155,42 @@ fn main() -> ExitCode {
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut headless = false;
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
let (flag, inline) = match a.split_once('=') {
Some((f, v)) if f.starts_with("--automation-") => (f, Some(v.to_string())),
_ => (a.as_str(), None),
};
match flag {
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
"--headless" => headless = true,
"--automation-read-root" | "--automation-write-root" => {
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
if slot.replace(dir).is_some() {
return Err(format!("{flag} is given twice: it takes one folder"));
}
}
other => return Err(format!("unknown mcp option `{other}`")),
}
}
if headless && connect.is_some() {
return Err("use either --connect or --headless".into());
}
let backend: Box<dyn Backend> = if headless {
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
.into());
}
let backend: Box<dyn Backend> = if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless {
Box::new(Headless::with_document())
} else if let Some(addr) = connect {
// Explicit address: fail loudly if the app isn't there.
@@ -163,7 +219,9 @@ fn commands() -> Result<(), String> {
fn convert(args: &[String]) -> Result<(), String> {
let mut files = vec![];
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, false);
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
(1.0f64, None::<u64>, None::<String>, false, false, false);
let mut decimals = None::<u64>;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
@@ -171,18 +229,27 @@ fn convert(args: &[String]) -> Result<(), String> {
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
"--outline-text" => outline_text = true,
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
"--replace-lossy" => replace_lossy = true,
"--editing-data" => editing_data = true,
f => files.push(f.to_string()),
}
}
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
let mut h = Headless::new();
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
let r = h
.call(
"engine.execute",
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
)
.map_err(|e| format!("export {output}: {e}"))?;
if editing_data {
let params = json!({"path": input, "editingData": "always"});
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?;
} else {
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
}
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
outln!("{r}");
Ok(())
}
@@ -241,24 +308,44 @@ fn run(args: &[String]) -> Result<(), String> {
let mut h = Headless::new();
let mut out = std::io::stdout().lock();
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
// steps (an export…) still run, unprinted.
let mut closed = false;
let mut emit = |v: Value| {
if closed {
return Ok(());
}
match writeln!(out, "{v}") {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
closed = true;
Ok(())
}
r => r.map_err(|e| e.to_string()),
}
};
if let Some(path) = &input {
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
emit(json!({"step": "open", "path": path, "result": r}))?;
} else if !matches!(steps.first(), Some(Step::Cmd(id, _)) if id == "file.new") {
h.ensure_document();
}
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
let mut results = vec![];
for step in steps {
match step {
let r = match step {
Step::Cmd(id, params) => {
let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("{id}: {e}"))?;
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
emit(json!({"step": "cmd", "command": id, "result": r}))?;
r
}
Step::Export(path) => {
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
r
}
}
};
results.push(r);
}
Ok(())
}
+218 -4
View File
@@ -1,7 +1,8 @@
//! `vectorcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
//!
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
//! average above the core count) they are noise, and the report says so.
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
//! average above three quarters of the cores, or on Windows more than three quarters of the
//! cores busy over a second) they are noise, and the report says so.
use std::time::Instant;
@@ -61,6 +62,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
}
/// 1-minute load average (macOS / Linux), if available.
#[cfg(not(windows))]
fn load_average() -> Option<f64> {
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
let text = match out {
@@ -70,6 +72,27 @@ fn load_average() -> Option<f64> {
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
}
/// The cores busy on average over one second (Windows), if available: the share of the
/// processors' time that `GetSystemTimes` reports as kernel or user time but not as idle time,
/// times `cores`. A load average also counts the threads waiting for a core.
#[cfg(windows)]
fn busy_cores(cores: usize) -> Option<f64> {
let times = || winsafe::GetSystemTimes().ok().map(|(idle, kernel, user)| [idle, kernel, user].map(u64::from));
let before = times()?;
std::thread::sleep(std::time::Duration::from_secs(1));
busy_share(before, times()?).map(|share| share * cores as f64)
}
/// The share of the processors' time between two `[idle, kernel, user]` readings of
/// `GetSystemTimes` that was busy (kernel time includes idle time). It is None when no time
/// passed, a time went back, or the idle time exceeds the kernel and user time.
#[cfg(any(windows, test))]
fn busy_share([idle0, kernel0, user0]: [u64; 3], [idle1, kernel1, user1]: [u64; 3]) -> Option<f64> {
let total = kernel1.checked_sub(kernel0)?.checked_add(user1.checked_sub(user0)?)?;
let busy = total.checked_sub(idle1.checked_sub(idle0)?)?;
(total > 0).then(|| busy as f64 / total as f64)
}
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
@@ -113,18 +136,171 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
Ok((handle, segment))
}
/// Resident memory of this process in MB (`ps`), if available.
#[cfg(not(windows))]
fn rss_mb() -> Option<f64> {
let pid = std::process::id().to_string();
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
}
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
#[cfg(windows)]
fn rss_mb() -> Option<f64> {
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
fn deep(n: usize) -> Result<(), String> {
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut rows: Vec<(String, f64)> = vec![];
let mut mem: Vec<(String, f64)> = vec![];
let mut note = |what: &str| {
if let Some(m) = rss_mb() {
mem.push((what.to_string(), m));
}
};
note("start");
// Editing every object of an n-path document.
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(n)?, None);
note("document built");
rows.push((format!("select all, {n} paths"), median_ms(3, || drop(s.execute("select.all", &json!({}))))));
rows.push((format!("nudge the selection ({n} paths)"), median_ms(5, || drop(s.execute("object.move", &json!({"dx": 1, "dy": 0}))))));
rows.push((format!("undo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.undo", &json!({}))))));
rows.push((format!("redo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.redo", &json!({}))))));
// Dragging the whole selection with the Selection tool: one pointer move (the preview).
{
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
use vectorcraft_geom::Vec2;
let view = vectorcraft_engine::ViewInfo { zoom: 1.0, ..Default::default() };
let first = s.doc().map_err(err)?.selection.objects.first().copied();
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
if let Some(down) = at {
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };
s.select_tool("selection", view).map_err(err)?;
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut dx = 0.0;
rows.push((
format!("drag the selection, per move ({n} paths)"),
median_ms(5, || {
dx += 2.0;
let _ = s.pointer(&ev(PointerKind::Drag, dx), view);
}),
));
s.pointer(&ev(PointerKind::Up, dx), view).map_err(err)?;
}
}
for _ in 0..40 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).map_err(err)?;
}
note("after 45 whole-selection edits");
drop(s);
// Live effects on 2,000 paths: render a page whose every object has the effect.
let opts = RenderOptions::default();
for (fx, params) in [("path.offsetPath", json!({"offset": 3})), ("distort.roughen", json!({"size": 5, "relative": true}))] {
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(2000)?, None);
s.execute("select.all", &json!({})).map_err(err)?;
s.execute("effect.apply", &json!({"effect": fx, "params": params})).map_err(err)?;
let doc = s.doc().map_err(err)?.doc.clone();
let mut r = Renderer::new();
r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts);
rows.push((format!("render, 2,000 paths with {fx} (warm)"), median_ms(5, || drop(r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts)))));
}
note("after live effects");
// Every Pathfinder operation on 300 overlapping paths.
for op in ["unite", "intersect", "exclude", "minusFront", "minusBack", "divide", "trim", "merge", "crop", "outline"] {
let ms = median_ms(1, || {
let mut s = vectorcraft_engine::Session::new();
if let Ok(d) = synthetic(300) {
s.add_document(d, None);
let _ = s.execute("select.all", &json!({})).and_then(|_| s.execute(&format!("object.pathfinder.{op}"), &json!({})));
}
});
rows.push((format!("Pathfinder {op}, 300 paths"), ms));
}
note("after Pathfinder");
// Typing one character in the middle of a 20,000-character area-type story.
let mut s = vectorcraft_engine::Session::new();
s.add_document(Document::new(1600.0, 1200.0), None);
let text: String = (0..2000).map(|i| if i % 12 == 11 { "paragraph.\n" } else { "lorem ips " }).collect();
let id = s.execute("text.create", &json!({"x": 20, "y": 20, "text": text, "size": 10, "area": {"width": 1500, "height": 1100}})).map_err(err)?;
let id = id.get("id").cloned().unwrap_or(json!(0));
let mut at = 10_000;
rows.push((
"type one character, 20,000-character area text".into(),
median_ms(9, || {
at += 1;
let _ = s.execute("text.editRange", &json!({"id": id, "start": at, "end": at, "insert": "x"}));
}),
));
note("after typing");
// Four embedded 24 MP photos drawn at fit page by three renderers, as the app's canvas, render
// worker and a panel each draw them.
{
let art = synthetic(3000)?;
let mut d = Document::new(1600.0, 1200.0);
let l = d.layers.first().map(|l| l.id);
for i in 0..4u32 {
let photo = Renderer::new().render(&art, 6000, 4000, Affine::scale(3.75 + f64::from(i) * 0.1), &opts).to_png()?;
let key = format!("photo {i}");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo));
let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0);
let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() };
let id = d.alloc_id();
d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?;
}
note("4 embedded 24 MP photos");
let mut renderers = [Renderer::new(), Renderer::new(), Renderer::new()];
let ms = median_ms(1, || {
for r in &mut renderers {
r.render(&d, 2880, 1800, Affine::scale(1.6), &opts);
}
});
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
note("after rendering the photos with 3 renderers");
}
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
let doc = synthetic(n)?;
let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default());
let saved = vectorcraft_format::save_file(&doc);
let reopened = vectorcraft_format::load(&saved).map_err(|e| e.to_string())?;
outln!(" output hash: SVG export {:016x}, save→open→save {:016x}", fnv(svg.as_bytes()), fnv(&vectorcraft_format::save_file(&reopened)));
outln!(" tracked (no budget yet)");
for (name, ms) in &rows {
outln!(" {name:<52} {ms:>10.2} ms");
}
// `rss_mb` reads the working set on Windows.
let memory = if cfg!(windows) { "working set" } else { "memory" };
for (what, mb) in &mem {
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
}
Ok(())
}
pub fn run(args: &[String]) -> Result<(), String> {
let mut n = 50_000usize;
let mut want_deep = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--deep" => want_deep = true,
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
other => return Err(format!("unknown perf option `{other}`")),
}
}
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let load = load_average();
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
#[cfg(not(windows))]
let (load, label) = (load_average(), "load average");
#[cfg(windows)]
let (load, label) = (busy_cores(cores), "cores busy");
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, {label} {}", load.map_or("?".into(), |l| format!("{l:.1}")));
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
if noisy {
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
@@ -200,6 +376,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
if let Some(e) = failed {
return Err(e);
}
if want_deep {
deep(n)?;
}
let mut over = 0;
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
@@ -217,3 +396,38 @@ pub fn run(args: &[String]) -> Result<(), String> {
_ => Err(format!("{over} budget(s) exceeded")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn busy_share_leaves_out_idle_time() {
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
// 10,000,000 of them idle.
assert_eq!(busy_share([7, 100, 50], [10_000_007, 25_000_100, 15_000_050]), Some(0.75));
// There is no share when no time passed, a time went back, or the idle time exceeds the
// kernel and user time.
assert_eq!(busy_share([7, 100, 50], [7, 100, 50]), None);
assert_eq!(busy_share([7, 100, 50], [8, 99, 60]), None);
assert_eq!(busy_share([0, 0, 0], [30, 10, 10]), None);
}
/// On Windows `busy_cores` gives a number between 0 and the cores.
#[cfg(windows)]
#[test]
fn windows_reads_how_many_cores_are_busy() {
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let busy = busy_cores(cores);
assert!(busy.is_some_and(|b| (0.0..=cores as f64).contains(&b)), "{busy:?} of {cores}");
}
/// On Windows `rss_mb` gives this process's working set: more than 0 MB and less than
/// 10,000 MB.
#[cfg(windows)]
#[test]
fn windows_reads_the_working_set() {
let mb = rss_mb();
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
}
}
+102 -1
View File
@@ -83,6 +83,89 @@ fn mcp_headless_over_stdio() {
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
/// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the
/// server starts, and the process then reads and writes only inside its roots.
#[test]
fn mcp_automation_roots() {
let base = tmp("roots");
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
std::fs::write(base.join("in/a.svg"), svg).unwrap();
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
std::fs::write(base.join("outside/victim.png"), b"keep this file").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(base.join("outside/b.svg"), base.join("in/escape.svg")).unwrap();
symlink(base.join("outside"), base.join("out/escape")).unwrap();
symlink(base.join("outside/victim.png"), base.join("out/victim.png")).unwrap();
symlink(base.join("outside/missing.png"), base.join("out/dangling.png")).unwrap();
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let fails = |args: &[&str], why: &str| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
};
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
fails(&["mcp", "--automation-write-root"], "needs a folder");
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
let mut child = Command::new(BIN)
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
let mut calls = vec![
call(1, "open_file", json!({"path": file("in", "a.svg")})),
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
call(3, "export", json!({"path": file("out", "a.png")})),
call(4, "export", json!({"path": file("in", "a.png")})),
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
call(6, "open_file", json!({"path": file("in", "../outside/b.svg")})),
call(7, "export", json!({"path": file("out", "../outside/traversal.png")})),
];
let mut expected_errors = vec![false, true, false, true, true, true, true];
#[cfg(unix)]
{
calls.extend([
call(8, "open_file", json!({"path": file("in", "escape.svg")})),
call(9, "export", json!({"path": file("out", "escape/escaped.png")})),
call(10, "export", json!({"path": file("out", "victim.png")})),
call(11, "export", json!({"path": file("out", "dangling.png")})),
]);
expected_errors.extend([true; 4]);
}
// An access error is per request: the server continues and can still save inside its root.
calls.push(call(12, "save_file", json!({"path": file("out", "allowed.vectorcraft")})));
expected_errors.push(false);
{
let mut stdin = child.stdin.take().unwrap();
for m in calls {
writeln!(stdin, "{m}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, expected_errors, "{replies:?}");
assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists());
assert!(base.join("out/allowed.vectorcraft").is_file());
for denied in ["traversal.png", "escaped.png", "missing.png"] {
assert!(!base.join("outside").join(denied).exists(), "{denied}");
}
assert_eq!(std::fs::read(base.join("outside/victim.png")).unwrap(), b"keep this file");
}
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
/// tests drive `Server::handle_line` directly, so the binary's own wiring (argument
/// parsing, logger install, stdout pollution, notification order) is untested: this
@@ -137,7 +220,7 @@ fn mcp_protocol_over_stdio() {
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 2));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
@@ -245,3 +328,21 @@ fn closed_stdout_ends_quietly() {
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0,
/// and its later steps still run; a failing step still fails.
#[test]
fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
let svg = tmp("closed-run.svg");
let _ = std::fs::remove_file(&svg);
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
assert!(svg.is_file(), "the export after the closed reader still ran");
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
assert!(!out.status.success());
}
+6
View File
@@ -83,6 +83,12 @@ fn help_lists_readable_formats() {
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
}
#[test]
fn help_lists_perf_deep() {
let out = ok(&["--help"]);
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
}
/// `info` reports the import warnings: an EPS read only up to an error names it.
#[test]
fn info_reports_why_an_eps_read_partly() {
+2 -1
View File
@@ -17,6 +17,7 @@ path = "src/main.rs"
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -27,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+8
View File
@@ -8,3 +8,11 @@ filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8766
# Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/
# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`, through cargo so
# it needs no shell (xtask/src/web_fonts.rs).
[[hooks]]
stage = "post_build"
command = "cargo"
command_arguments = ["xtask", "web-fonts"]
+109
View File
@@ -0,0 +1,109 @@
//! Craft-fonts faces served beside the wasm (`fonts/<sha16>/…`, copied by
//! `cargo xtask web-fonts`) instead of embedded: [`load_startup_fonts`] fetches the `startup`
//! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks
//! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped:
//! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing.
use std::cell::Cell;
use std::rc::Rc;
use vectorcraft_text::{FontDb, WEB_FONTS, WebFont};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use wasm_bindgen_futures::JsFuture;
/// Per startup face: the page shows its loading text meanwhile.
const STARTUP_TIMEOUT_MS: i32 = 20_000;
/// Per background face.
const BACKGROUND_TIMEOUT_MS: i32 = 120_000;
/// Background fetches at once, leaving the connection to the app's own requests.
const BACKGROUND_PARALLEL: usize = 4;
thread_local! {
/// The background fetch runs once per page, though a graphics restart creates the app again.
static BACKGROUND_STARTED: Cell<bool> = const { Cell::new(false) };
}
/// Fetch and register the `startup` faces (one after the other). Returns how many loaded.
pub async fn load_startup_fonts() -> usize {
let mut loaded = 0;
for f in WEB_FONTS.iter().filter(|f| f.startup) {
if load(f, STARTUP_TIMEOUT_MS).await {
loaded += 1;
}
}
loaded
}
/// Fetch and register the `background` faces, [`BACKGROUND_PARALLEL`] at a time, asking `ctx` for
/// a frame after each so the font menus and the canvas pick it up. Only the first call does
/// anything.
pub fn spawn_background_fonts(ctx: egui::Context) {
if BACKGROUND_STARTED.with(|s| s.replace(true)) {
return;
}
let queue: Rc<Vec<&'static WebFont>> = Rc::new(WEB_FONTS.iter().filter(|f| !f.startup).collect());
let next = Rc::new(Cell::new(0usize));
for _ in 0..BACKGROUND_PARALLEL.min(queue.len()) {
let (queue, next, ctx) = (queue.clone(), next.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
loop {
let i = next.get();
next.set(i + 1);
let Some(f) = queue.get(i) else { break };
if load(f, BACKGROUND_TIMEOUT_MS).await {
ctx.request_repaint();
}
}
});
}
}
/// Fetch one face and add it to the font database. False (and a console warning) on failure.
async fn load(f: &WebFont, timeout_ms: i32) -> bool {
let bytes = match fetch(f.url, f.integrity, timeout_ms).await {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("web font {} {}: {e}", f.family, f.style);
return false;
}
};
if FontDb::global().add_font(bytes) == 0 {
log::warn!("web font {} {}: not a font, or already loaded", f.family, f.style);
return false;
}
true
}
/// GET `url` (relative to the page) with Subresource Integrity `integrity`, aborted after
/// `timeout_ms`.
async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
let abort = web_sys::AbortController::new().map_err(js)?;
let init = web_sys::RequestInit::new();
init.set_method("GET");
init.set_integrity(integrity);
init.set_signal(Some(&abort.signal()));
let on_timeout = {
let abort = abort.clone();
Closure::<dyn FnMut()>::new(move || abort.abort())
};
let timer = window.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), timeout_ms).map_err(js)?;
let result = async {
let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?;
if !response.ok() {
return Err(format!("HTTP {} for {url}", response.status()));
}
let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}
.await;
window.clear_timeout_with_handle(timer);
// Kept alive until the timer can no longer fire.
drop(on_timeout);
result
}
fn js(e: wasm_bindgen::JsValue) -> String {
format!("{e:?}")
}
+2
View File
@@ -14,6 +14,8 @@
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(target_arch = "wasm32")]
+17 -5
View File
@@ -6,7 +6,7 @@ use std::rc::Rc;
use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour};
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
@@ -64,6 +64,9 @@ pub fn start() {
wasm_bindgen_futures::spawn_local(async move {
// Before the first frame, which looks for copies a crash left behind.
let locks = WebLocks::start(RECOVERY_PREFIX).await;
// Fallback fonts the first frame needs (Noto Sans Arabic), fetched while the loading text
// shows; the rest load in the background once the app runs.
crate::fonts::load_startup_fonts().await;
let host = Host {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
@@ -99,6 +102,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
web_options(),
Box::new(move |cc| {
let ctx = &cc.egui_ctx;
crate::fonts::spawn_background_fonts(ctx.clone());
let loss = GraphicsLoss::default();
watch_canvas(&page, &loss, ctx);
if let Some(rs) = &cc.wgpu_render_state {
@@ -200,7 +204,9 @@ fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
let Some(data) = e.clipboard_data() else { return };
let Some(files) = data.files().filter(|f| f.length() > 0) else { return };
if data.get_data("text").is_ok_and(|t| !t.is_empty()) {
// Text is egui's, but for the address a browser's Copy Image puts next to the picture.
// Blank text is not a caption. A picture pasted with it is still the picture.
if data.get_data("text").is_ok_and(|t| !t.trim().is_empty() && !is_address(&t)) {
return;
}
e.prevent_default();
@@ -303,7 +309,8 @@ fn notice(lines: &[&str]) {
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
@@ -396,7 +403,10 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
let inbox = picked.clone();
let ctx = place_ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts));
let dialog =
fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
});
let Some(files) = dialog.pick_files().await else {
return;
};
@@ -413,7 +423,9 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| d.add_filter(name, exts));
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| {
d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
});
let Some(file) = dialog.pick_file().await else {
return;
};
+34 -3
View File
@@ -11,7 +11,7 @@ workspace = true
[features]
default = ["wgpu", "accessibility"]
wgpu = ["eframe/wgpu"]
wgpu = ["eframe/wgpu", "dep:winsafe"]
accessibility = ["eframe/accesskit"]
# Build with --no-default-features --features windows7 and the win7 Rust target.
windows7 = ["eframe/glow"]
@@ -19,6 +19,7 @@ windows7 = ["eframe/glow"]
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-text = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
@@ -33,18 +34,48 @@ webbrowser = { workspace = true }
# The window's monitors, size and position (the same winit eframe runs on).
winit = { version = "0.30", default-features = false }
# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows.
[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies]
craft-print = { workspace = true }
[target.'cfg(windows)'.dependencies]
clipboard-win = { workspace = true }
# Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the
# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over
# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter.
winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true }
# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as
# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
[target.'cfg(target_os = "macos")'.dependencies]
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
# The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR
# Apache-2.0 OR Zlib, already in the build through eframe and winit.
raw-window-handle = "0.6"
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
# already in the build through eframe.
pollster = "1"
[target.'cfg(target_os = "linux")'.dependencies]
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
+185
View File
@@ -0,0 +1,185 @@
//! Parse the control port and automation roots before starting any desktop services. A malformed
//! flag must not silently remove a root and leave the control channel unconfined, or leave the app
//! running without the control channel that was asked for.
use std::ffi::OsString;
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. CLI values override environment defaults
/// once; a second occurrence is an error, including when the two spellings are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
/// `--control <port>` or `--control=<port>`, given at most once.
#[derive(Default)]
pub struct ControlArg {
pub port: Option<u16>,
}
impl ControlArg {
/// Returns whether this argument is `--control`. The next argument is its port whatever it
/// is, so `--control --automation-read-root` reports the flag rather than reading it as a root.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let value = match arg.strip_prefix("--control") {
Some("") => args.next(),
Some(rest) => match rest.strip_prefix('=') {
Some(value) => Some(value.to_owned()),
None => return Ok(false),
},
None => return Ok(false),
};
if self.port.is_some() {
return Err("--control given twice".into());
}
self.port = Some(port("--control", value.as_deref())?);
Ok(true)
}
}
/// `VECTORCRAFT_CONTROL_PORT`, for when `--control` isn't given: unset or empty is no control
/// channel, anything else must be a port.
pub fn env_control_port(value: Option<OsString>) -> Result<Option<u16>, String> {
match value {
Some(v) if !v.is_empty() => port("VECTORCRAFT_CONTROL_PORT", Some(&v.to_string_lossy())).map(Some),
_ => Ok(None),
}
}
/// A port from 1 to 65535 (the server would listen on 0's random port without saying which).
fn port(name: &str, value: Option<&str>) -> Result<u16, String> {
let need = format!("{name} needs a port number from 1 to 65535");
match value {
None => Err(need),
Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_override_environment_once_and_leave_other_arguments() {
let mut roots = AutomationArgs::new(Some("environment".into()), None);
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_preserve_environment_roots() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--control"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
fn control(args: &[&str]) -> Result<Option<u16>, String> {
let mut control = ControlArg::default();
let mut args = args.iter().map(|a| a.to_string());
while let Some(a) = args.next() {
assert!(control.consume(&a, &mut args)?, "{a}");
}
Ok(control.port)
}
#[test]
fn control_needs_a_port_from_1_to_65535() {
let need = "--control needs a port number from 1 to 65535";
assert_eq!(control(&["--control"]), Err(need.into()));
assert_eq!(control(&["--control", "--automation-read-root"]), Err(format!("{need}, not `--automation-read-root`")));
for bad in ["abc", "0", "70000", "-1", ""] {
assert_eq!(control(&["--control", bad]), Err(format!("{need}, not `{bad}`")));
assert_eq!(control(&[&format!("--control={bad}")]), Err(format!("{need}, not `{bad}`")));
}
assert_eq!(control(&["--control", "7979"]), Ok(Some(7979)));
assert_eq!(control(&["--control=65535"]), Ok(Some(65535)));
assert_eq!(control(&["--control", "7979", "--control=7980"]), Err("--control given twice".into()));
}
#[test]
fn other_arguments_are_not_control() {
let mut control = ControlArg::default();
let mut args = ["7979".to_owned()].into_iter();
for other in ["--controls", "--control-port", "drawing.svg", "--in-window-menus"] {
assert!(!control.consume(other, &mut args).unwrap(), "{other}");
}
assert_eq!(args.next().as_deref(), Some("7979"));
assert_eq!(control.port, None);
}
#[test]
fn control_port_variable_is_a_port_or_unset() {
assert_eq!(env_control_port(None), Ok(None));
assert_eq!(env_control_port(Some("".into())), Ok(None));
assert_eq!(env_control_port(Some("7979".into())), Ok(Some(7979)));
let need = "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
assert_eq!(env_control_port(Some("abc".into())), Err(format!("{need}, not `abc`")));
assert_eq!(env_control_port(Some("0".into())), Err(format!("{need}, not `0`")));
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
assert_eq!(env_control_port(Some(OsString::from_vec(vec![0x39, 0xff]))), Err(format!("{need}, not `9\u{fffd}`")));
}
}
}
+93 -46
View File
@@ -2,20 +2,21 @@
//! (`Services::system_clipboard`).
//!
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that
//! Paste reads each of them and EPS, a bitmap as PNG, else as the device-independent bitmap that
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
//! else the PNG as a bitmap; Paste reads text and bitmaps, and on macOS the pasteboard's SVG, PDF
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
use std::io::{Cursor, Read};
use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour};
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::SystemClipboard;
/// This platform's system clipboard.
@@ -27,6 +28,34 @@ pub fn system_clipboard() -> Box<dyn SystemClipboard> {
cb
}
/// The registered Windows clipboard formats a flavour is written under (and read from, first held
/// first).
#[cfg_attr(not(windows), allow(dead_code))]
fn windows_formats(mime: &str) -> &'static [&'static str] {
match mime {
SVG => &["image/svg+xml"],
PDF => &["Portable Document Format", "application/pdf"],
EPS => &["Encapsulated PostScript", "application/postscript"],
PNG => &["PNG"],
_ => &[],
}
}
/// The macOS pasteboard types Paste reads for `mime` (`image/*`: any bitmap), best first, each with
/// the MIME type of its data. They are the system's uniform type identifiers (the values of
/// `NSPasteboardTypePDF`, `NSPasteboardTypePNG` and `NSPasteboardTypeTIFF` among them) and the
/// older name some apps still give PDF. Text is read through arboard.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn pasteboard_types(mime: &str) -> &'static [(&'static str, &'static str)] {
match mime {
SVG => &[("public.svg-image", SVG)],
PDF => &[("com.adobe.pdf", PDF), ("Apple PDF pasteboard type", PDF)], // brand-ok: the system's type identifier
EPS => &[("com.adobe.encapsulated-postscript", EPS)], // brand-ok: the system's type identifier
BITMAP => &[("public.png", PNG), ("public.tiff", "image/tiff")],
_ => &[],
}
}
/// The text flavour among `flavours`.
fn text_of(flavours: &[Flavour]) -> Option<String> {
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
@@ -127,27 +156,15 @@ mod win {
use std::num::NonZeroU32;
use clipboard_win::{formats, options::NoClear, raw};
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
use super::*;
/// The registered clipboard formats a flavour is written under (and read from, first held
/// first).
fn names(mime: &str) -> &'static [&'static str] {
match mime {
SVG => &["image/svg+xml"],
PDF => &["Portable Document Format", "application/pdf"],
PNG => &["PNG"],
_ => &[],
}
}
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
/// other or a `CF_BITMAP` is: the system converts between them.
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
windows_formats(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
}
fn err(e: impl std::fmt::Display) -> String {
@@ -350,27 +367,30 @@ mod portable {
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
return Some(f);
}
mimes.iter().find_map(|m| match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
BITMAP => {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::bitmap() {
return Some(f);
mimes.iter().find_map(|m| {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::read(m) {
return Some(f);
}
match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
BITMAP => {
let img = cb.get_image().ok()?;
let rgba =
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
png_flavour(&rgba)
}
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
png_flavour(&rgba)
_ => None,
}
_ => None,
})
}
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
/// menu item waits for text.
/// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
/// take it, the menu item waits for text.
fn has(&mut self, mimes: &[&'static str]) -> bool {
#[cfg(target_os = "macos")]
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
if super::mac::has(mimes) {
return true;
}
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
@@ -378,7 +398,8 @@ mod portable {
}
}
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
/// pictures TIFF only, decoded).
#[cfg(target_os = "macos")]
mod mac {
use objc2::rc::autoreleasepool;
@@ -387,24 +408,20 @@ mod mac {
use super::*;
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
/// `NSPasteboardTypeTIFF`), with their MIME types.
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
/// Does the pasteboard hold a bitmap? Asks for its types only.
pub fn has_bitmap() -> bool {
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
pub fn has(mimes: &[&'static str]) -> bool {
autoreleasepool(|_| {
let types = TYPES.map(|(t, _)| NSString::from_str(t));
let types: Vec<_> = mimes.iter().flat_map(|m| pasteboard_types(m)).map(|(t, _)| NSString::from_str(t)).collect();
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
!types.is_empty() && NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
})
}
/// The pasteboard's bitmap, PNG before TIFF.
pub fn bitmap() -> Option<Flavour> {
/// The pasteboard's data for `mime`, the first of its types held ([`pasteboard_types`]).
pub fn read(mime: &str) -> Option<Flavour> {
autoreleasepool(|_| {
let pasteboard = NSPasteboard::generalPasteboard();
TYPES.into_iter().find_map(|(t, mime)| {
pasteboard_types(mime).iter().find_map(|&(t, mime)| {
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
(!data.is_empty()).then_some(Flavour { mime, data })
})
@@ -414,7 +431,7 @@ mod mac {
#[cfg(test)]
mod tests {
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
use vectorcraft_engine::cmd::clipboard::{EMF, PASTE_ORDER};
use super::*;
@@ -448,6 +465,36 @@ mod tests {
assert!(copied_file(vec![], &PASTE_ORDER).is_none());
}
/// What another app copied as vector art is asked for under the names the platforms give it,
/// and read as the flavour the paste imports (#1499).
#[test]
fn vector_art_is_read_under_each_platforms_names() {
let mac = |m| pasteboard_types(m).iter().map(|t| t.0).collect::<Vec<_>>();
assert_eq!(mac(PDF), ["com.adobe.pdf", "Apple PDF pasteboard type"]);
assert_eq!(mac(EPS), ["com.adobe.encapsulated-postscript"]);
assert_eq!(mac(SVG), ["public.svg-image"]);
assert_eq!(pasteboard_types(BITMAP), [("public.png", PNG), ("public.tiff", "image/tiff")]);
assert!(windows_formats(EPS).contains(&"Encapsulated PostScript") && windows_formats(PDF).contains(&"Portable Document Format"));
for asked in PASTE_ORDER {
// Each type's data is the flavour asked for (a bitmap its own type): what
// `clipboard.import*` then reads. Text and metafiles come another way.
assert!(pasteboard_types(asked).iter().all(|(_, mime)| *mime == asked || asked == BITMAP), "{asked}");
assert_eq!(pasteboard_types(asked).is_empty(), [TEXT, EMF].contains(&asked), "{asked}");
}
// Vector art before text, text before bitmaps.
let at = |m| PASTE_ORDER.iter().position(|x| *x == m).unwrap();
assert!(at(PDF) < at(EPS) && at(EPS) < at(TEXT) && at(TEXT) < at(BITMAP));
}
/// An .eps file copied in a file manager pastes as EPS.
#[test]
fn a_copied_eps_file_pastes() {
let eps = dir("eps").join("equation.eps");
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
}
#[test]
fn a_crlf_left_by_the_uri_list_is_ignored() {
let red = dir("crlf").join("red.png");
+178
View File
@@ -0,0 +1,178 @@
//! The GPU the Wayland compositor draws with ([`crate::gpu`] ranks it first under Automatic).
//!
//! A window drawn on another GPU reaches the compositor as that GPU's buffers, and the compositor
//! may not show them: it can refuse them and end the window's connection (#502), or take them and
//! show nothing, with no error on either side, so neither the restart on a failed adapter nor
//! the first-frame watch notices. The second happened on a hybrid laptop whose Hyprland runs on
//! the NVIDIA GPU (`AQ_DRM_DEVICES`) while the built-in panel hangs off the Intel one: the panel
//! rule picked Intel and the window stayed empty. Which GPU drives a display is a guess at the
//! compositor's GPU; the compositor states it, as the `main_device` of its linux-dmabuf feedback
//! (protocol version 4): the device it imports client buffers on.
#[cfg(target_os = "linux")]
use wayland_client::globals::{GlobalListContents, registry_queue_init};
#[cfg(target_os = "linux")]
use wayland_client::protocol::wl_registry::{self, WlRegistry};
#[cfg(target_os = "linux")]
use wayland_client::{Connection, Dispatch, QueueHandle};
#[cfg(target_os = "linux")]
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{self, ZwpLinuxDmabufFeedbackV1};
#[cfg(target_os = "linux")]
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{self, ZwpLinuxDmabufV1};
/// PCI `(vendor, device)` ids of the GPU the Wayland compositor draws with. `None` outside a
/// Wayland session, when the compositor doesn't say (linux-dmabuf older than version 4), when its
/// device isn't a PCI GPU, and on other systems.
#[cfg(target_os = "linux")]
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
let device = match main_device() {
Ok(device) => device,
Err(why) => {
log::debug!("the compositor's GPU is unknown: {why}");
return None;
}
};
let pci = pci_ids(std::path::Path::new("/sys/dev/char"), device);
match pci {
Some((vendor, id)) => log::info!("the compositor draws with {vendor:04x}:{id:04x}"),
None => log::debug!("the compositor's device {:?} has no PCI ids", major_minor(device)),
}
pci
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
None
}
/// The `main_device` of the compositor's default linux-dmabuf feedback, asked on a connection of
/// its own that is closed again before the window opens.
#[cfg(target_os = "linux")]
fn main_device() -> Result<u64, String> {
let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?;
let (globals, mut queue) = registry_queue_init::<Feedback>(&conn).map_err(|e| e.to_string())?;
let qh = queue.handle();
// Version 4 brought the feedback; older compositors only list formats.
let dmabuf: ZwpLinuxDmabufV1 = globals.bind(&qh, 4..=4, ()).map_err(|e| format!("linux-dmabuf version 4: {e}"))?;
let _feedback = dmabuf.get_default_feedback(&qh, ());
let mut state = Feedback::default();
// The compositor sends the whole feedback, ending with `done`, in reply; the bound only keeps
// a compositor that never ends it from holding up the start.
for _ in 0..4 {
if state.done {
break;
}
queue.roundtrip(&mut state).map_err(|e| e.to_string())?;
}
// Dropping the connection releases the objects.
let device = state.main_device.ok_or("the compositor named no main device")?;
// A `dev_t`, in this machine's byte order.
let device: [u8; 8] = device.try_into().map_err(|d: Vec<u8>| format!("a {}-byte device number", d.len()))?;
Ok(u64::from_ne_bytes(device))
}
/// What [`main_device`] collects from the feedback events.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct Feedback {
main_device: Option<Vec<u8>>,
done: bool,
}
#[cfg(target_os = "linux")]
impl Dispatch<WlRegistry, GlobalListContents> for Feedback {
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
#[cfg(target_os = "linux")]
impl Dispatch<ZwpLinuxDmabufV1, ()> for Feedback {
// Version 4 sends no format or modifier events: the feedback replaces them.
fn event(_: &mut Self, _: &ZwpLinuxDmabufV1, _: zwp_linux_dmabuf_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
#[cfg(target_os = "linux")]
impl Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for Feedback {
fn event(
state: &mut Self,
_: &ZwpLinuxDmabufFeedbackV1,
event: zwp_linux_dmabuf_feedback_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } => state.main_device = Some(device),
zwp_linux_dmabuf_feedback_v1::Event::Done => state.done = true,
_ => {}
}
}
}
/// The major and minor numbers of a Linux `dev_t`, as glibc's and musl's `major()` and `minor()`
/// split it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn major_minor(device: u64) -> (u64, u64) {
let major = ((device & 0x0000_0000_000f_ff00) >> 8) | ((device & 0xffff_f000_0000_0000) >> 32);
let minor = (device & 0x0000_0000_0000_00ff) | ((device & 0x0000_0fff_fff0_0000) >> 12);
(major, minor)
}
/// The PCI ids of the GPU behind DRM device `device` (its `cardN` or `renderDN` node), from
/// `<sys_dev_char>/MAJOR:MINOR/device/{vendor,device}`.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn pci_ids(sys_dev_char: &std::path::Path, device: u64) -> Option<(u32, u32)> {
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
let (major, minor) = major_minor(device);
let gpu = sys_dev_char.join(format!("{major}:{minor}")).join("device");
Some((read_hex(gpu.join("vendor"))?, read_hex(gpu.join("device"))?))
}
#[cfg(test)]
mod tests {
/// glibc's `makedev()`.
fn makedev(major: u64, minor: u64) -> u64 {
((major & 0x0000_0fff) << 8) | ((major & 0xffff_f000) << 32) | (minor & 0x0000_00ff) | ((minor & 0xffff_ff00) << 12)
}
#[test]
fn device_numbers_split_into_major_and_minor() {
// DRM nodes: card1 is 226:1, renderD128 is 226:128.
assert_eq!(super::major_minor(0xe201), (226, 1));
assert_eq!(super::major_minor(0xe280), (226, 128));
for (major, minor) in [(0, 0), (226, 129), (511, 1_048_575), (0xfff, 0xff), (0x1000, 0x100), (0xffff_ffff, 0xffff_ffff)] {
assert_eq!(super::major_minor(makedev(major, minor)), (major, minor));
}
}
#[cfg(target_os = "linux")]
#[test]
fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("vectorcraft-dev-char-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU.
std::fs::create_dir_all(dir.join("226:128/device"))?;
std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?;
std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?;
// A device that isn't a PCI GPU (a virtual one) has no ids.
std::fs::create_dir_all(dir.join("226:0/device"))?;
let render = super::pci_ids(&dir, makedev(226, 128));
let virtual_gpu = super::pci_ids(&dir, makedev(226, 0));
let missing = super::pci_ids(&dir, makedev(226, 1));
std::fs::remove_dir_all(&dir)?;
assert_eq!(render, Some((0x10de, 0x25ac)));
assert_eq!(virtual_gpu, None);
assert_eq!(missing, None);
Ok(())
}
#[test]
fn the_compositor_names_a_pci_gpu_or_nothing() {
// Whatever this session has: no compositor (CI), one that doesn't say, or a PCI GPU.
if let Some((vendor, _)) = super::compositor_gpu() {
assert_ne!(vendor, 0);
}
}
}
+651 -36
View File
@@ -4,10 +4,26 @@
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the power preference
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU the Wayland
//! compositor draws with ([`crate::compositor_gpu`]), the GPU that drives a display and the one
//! that drives the primary display ([`display_gpus`]), the power preference
//! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545).
//! eframe is handed [`selector`], which takes the first of that order.
//!
//! A GPU without a monitor is the wrong one even when it can present: on a desktop whose monitors
//! all hang off the discrete GPU, power saving picked the Ryzen's integrated GPU, Windows had to
//! present every frame across adapters, and the integrated GPU's driver reset under that, which
//! lost the graphics device, or took the desktop's compositor and every monitor down for minutes
//! (pdfcraft#378). So, when Preferences say Automatic, the GPU that drives the display the user
//! looks at comes first, and the power preference only orders the rest. Windows says which adapter
//! drives which display (`EnumDisplayDevices`), Linux through sysfs; elsewhere, or when nothing
//! matches, the order is the power preference's as before. Power Saving and High Performance,
//! chosen by the user, order by kind as they always did.
//!
//! Under Wayland, the compositor's own GPU comes before the displays: the compositor says which
//! GPU it draws with, and frames from another one may never reach the screen, with no error, even
//! from the GPU behind the built-in panel when the compositor runs on the discrete one.
//!
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
//! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while
@@ -15,15 +31,23 @@
//! the graphics fail while the window is starting up, [`finish`] starts the app again without
//! that adapter, as the next one in the order would be tried. Each restart leaves out one more
//! adapter, so it ends when none is left.
//!
//! An adapter can also hang instead of failing: on a hybrid laptop with High Performance chosen,
//! the discrete GPU never put the window's first frame on the screen, with no error and no panic,
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
//! the app again without that adapter in the same way.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
use eframe::wgpu::{self, Backend, DeviceType, PowerPreference};
use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
/// The adapters a restart leaves out, those the window failed on, as comma-separated
/// `backend:vendor:device` keys (`Vulkan:1002:164e`). Set by the app itself when it starts again.
/// The adapters a restart leaves out, those the window failed on, as comma-separated [`key`]s
/// (`Vulkan:1002:164e`, `Metal:Intel Iris Pro Graphics`). Set by the app itself when it starts
/// again.
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
@@ -36,7 +60,12 @@ const HELP: &str =
/// Frames the UI ran before the failure, below which it was still starting up. The failure in
/// #502 came on the first or second frame; later, the adapter has proved itself, and a lost
/// window is something else (such as the compositor restarting).
const STARTUP_FRAMES: u64 = 10;
pub const STARTUP_FRAMES: u64 = 10;
/// How long the window's first frame may take to reach the screen, from the app's creation,
/// before its adapter counts as hanging ([`watch_first_frame`]). The first frame draws the whole
/// interface and compiles its shaders, a second at most on a slow machine.
pub const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(20);
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
@@ -57,14 +86,51 @@ pub fn power_preference(pref: Option<&str>, env: Option<PowerPreference>) -> Pow
}
}
/// Whether the adapter is the app's to choose: neither `WGPU_POWER_PREF` nor Preferences name a
/// kind of GPU (the arguments of [`power_preference`]). Then the GPU that drives the display comes
/// first ([`display_gpus`]); a user's own choice is kept as it is.
pub fn automatic(pref: Option<&str>, env: Option<PowerPreference>) -> bool {
env.is_none() && !matches!(pref, Some("highPerformance" | "lowPower"))
}
/// The GPUs to rank first: those that drive a display, when the choice is [`automatic`].
pub fn preferred_displays(pref: Option<&str>, env: Option<PowerPreference>) -> Vec<DisplayGpu> {
if automatic(pref, env) { display_gpus() } else { Vec::new() }
}
/// The GPU to rank first: the one the Wayland compositor draws with, when the choice is
/// [`automatic`].
pub fn preferred_compositor(pref: Option<&str>, env: Option<PowerPreference>) -> Option<(u32, u32)> {
if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None }
}
/// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL,
/// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan
/// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's
/// `igvk64.dll`, #806) crashed the app before its window appeared. DX12 is Windows' own backend,
/// as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool) -> Backends {
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
}
/// The largest texture side to ask the device for, which bounds the window's surface: the
/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than
/// that keeps its surface (#1459). Never below `default`, which is what was asked for before.
pub fn surface_limit(default: u32, adapter: u32) -> u32 {
default.max(adapter)
}
/// One adapter, as [`adapter_order`] sees it.
#[derive(Clone, Debug)]
pub struct Candidate {
/// Identifies the adapter across a restart: `backend:vendor:device` ([`key`]).
/// Identifies the adapter across a restart ([`key`]).
pub key: String,
pub name: String,
pub backend: Backend,
pub device_type: DeviceType,
/// PCI vendor and device ids, to match a [`DisplayGpu`]; zero where the backend reports none
/// (Metal, and OpenGL's device id).
pub pci: (u32, u32),
/// Reports that it can present to the window's surface.
pub presents: bool,
}
@@ -73,33 +139,189 @@ impl Candidate {
fn new(adapter: &wgpu::Adapter, surface: Option<&wgpu::Surface<'_>>) -> Self {
let info = adapter.get_info();
Self {
key: key(info.backend, info.vendor, info.device),
key: key(info.backend, info.vendor, info.device, &info.name),
presents: surface.is_none_or(|s| adapter.is_surface_supported(s)),
name: info.name,
backend: info.backend,
device_type: info.device_type,
pci: (info.vendor, info.device),
}
}
/// Whether it is the GPU the compositor draws with (`compositor`, by PCI ids).
fn composites(&self, compositor: Option<(u32, u32)>) -> bool {
compositor == Some(self.pci)
}
/// Whether it drives one of `displays`, and whether that is the primary one.
fn drives(&self, displays: &[DisplayGpu]) -> (bool, bool) {
let shown = displays.iter().find(|g| g.pci == self.pci);
(shown.is_some(), shown.is_some_and(|g| g.primary))
}
}
/// A GPU with a monitor attached, as the system reports it ([`display_gpus`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DisplayGpu {
/// PCI `(vendor, device)` ids, as wgpu's `AdapterInfo` reports them.
pub pci: (u32, u32),
/// Drives the display the user most likely looks at: on Windows the primary display, on Linux
/// a built-in panel (`eDP`, `LVDS` or `DSI`).
pub primary: bool,
}
impl std::fmt::Display for DisplayGpu {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:04x}:{:04x}{}", self.pci.0, self.pci.1, if self.primary { " (primary)" } else { "" })
}
}
/// The GPUs that drive a display, each once. Windows enumerates its display devices
/// (`EnumDisplayDevices`: those attached to the desktop, and the primary one); Linux reads the
/// connected connectors in `/sys/class/drm`. macOS and the rest report nothing, so the power
/// preference decides as before; so does a system whose GPUs report no PCI ids.
pub fn display_gpus() -> Vec<DisplayGpu> {
#[cfg(windows)]
{
windows_display_gpus()
}
#[cfg(target_os = "linux")]
{
linux_display_gpus(std::path::Path::new("/sys/class/drm"))
}
#[cfg(not(any(windows, target_os = "linux")))]
{
Vec::new()
}
}
/// Record that the GPU `pci` drives a display, merging it with its other displays.
#[cfg(any(windows, target_os = "linux", test))]
fn add_display_gpu(gpus: &mut Vec<DisplayGpu>, pci: (u32, u32), primary: bool) {
match gpus.iter_mut().find(|g| g.pci == pci) {
Some(gpu) => gpu.primary |= primary,
None => gpus.push(DisplayGpu { pci, primary }),
}
}
/// Windows' display devices: one per GPU output; those `ATTACHED_TO_DESKTOP` show a monitor, one
/// of them the `PRIMARY_DEVICE`. The registry doesn't tell a user which adapter drives a display,
/// and WMI reports a resolution for a GPU without a monitor, so this is the way.
#[cfg(windows)]
fn windows_display_gpus() -> Vec<DisplayGpu> {
use winsafe::co::DISPLAY_DEVICE as Flags;
let mut gpus = Vec::new();
// A machine has a handful of display devices; the cap only bounds a runaway enumeration.
for device in winsafe::EnumDisplayDevices(None, None).take(256) {
let device = match device {
Ok(d) => d,
// The enumeration ends with "no more items" or, from a driver, with any other error.
Err(e) => {
log::debug!("display devices: {e}");
break;
}
};
if !device.StateFlags.has(Flags::ATTACHED_TO_DESKTOP) {
continue;
}
// Remote Desktop and other non-PCI devices (`ROOT\BasicDisplay\0000`) are left out.
let Some(pci) = pci_ids(&device.DeviceID()) else { continue };
add_display_gpu(&mut gpus, pci, device.StateFlags.has(Flags::PRIMARY_DEVICE));
}
gpus
}
/// The PCI `(vendor, device)` of a Windows device id such as
/// `PCI\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1`, any case; `None` for anything else.
#[cfg(any(windows, test))]
fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
let field = |key: &str| {
device_id.split(['\\', '&']).find_map(|part| {
let (name, hex) = part.split_at_checked(key.len())?;
if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None }
})
};
Some((field("VEN_")?, field("DEV_")?))
}
/// Linux: every connector `cardN-CONNECTOR` under `drm` whose `status` is `connected`, with the
/// PCI ids of its card (`cardN/device/{vendor,device}`). A built-in panel is the primary display.
#[cfg(any(target_os = "linux", test))]
fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
let mut gpus = Vec::new();
let Ok(entries) = std::fs::read_dir(drm) else { return gpus };
// A machine has a handful of connectors; the cap only bounds a pathological sysfs.
for entry in entries.flatten().take(256) {
let name = entry.file_name();
let Some((card, connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected");
if !connected {
continue;
}
let device = drm.join(card).join("device");
let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) else { continue };
add_display_gpu(&mut gpus, (v, d), is_internal_panel(connector));
}
gpus
}
/// A laptop's built-in panel: an embedded DisplayPort, LVDS or DSI connector.
#[cfg(any(target_os = "linux", test))]
fn is_internal_panel(connector: &str) -> bool {
["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind))
}
/// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them).
fn key(backend: Backend, vendor: u32, device: u32) -> String {
format!("{backend:?}:{vendor:04x}:{device:04x}")
/// Metal reports no ids (`0000:0000` for every GPU), so an adapter without them is `backend:name`
/// (`Metal:Intel Iris Pro Graphics`): otherwise leaving out the one that failed would leave out
/// every GPU of a dual-GPU Mac (#651). Commas, which separate [`SKIP_ENV`]'s keys, become spaces.
fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String {
if vendor == 0 && device == 0 {
format!("{backend:?}:{}", name.replace(',', " ").trim())
} else {
format!("{backend:?}:{vendor:04x}:{device:04x}")
}
}
/// The order to try `candidates` in (their indices): only those that present to the window and
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
/// software; native backends before OpenGL; then by `power`; then DX12 before Vulkan, keeping the
/// system's order among equals (all of it for [`PowerPreference::None`]). Windows lists each GPU
/// under both, and Intel's Vulkan driver made the whole window flicker black where DX12 and OpenGL
/// didn't (#545); elsewhere there is no DX12 adapter, so the order is unchanged.
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec<usize> {
/// software; native backends before OpenGL; the GPU the compositor draws with (`compositor`); the
/// GPUs that drive one of `displays`, the primary display's first; then by `power`; then DX12
/// before Vulkan, keeping the system's order among equals (all of it for
/// [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's
/// Vulkan driver made the whole window flicker black where DX12 and OpenGL didn't (#545);
/// elsewhere there is no DX12 adapter, so the order is unchanged.
///
/// With no `displays`, or none a candidate's PCI ids match (OpenGL reports no device id, Metal no
/// ids at all), the order is the power preference's alone, as it was.
pub fn adapter_order(
candidates: &[Candidate],
power: PowerPreference,
displays: &[DisplayGpu],
compositor: Option<(u32, u32)>,
named: Option<&str>,
skip: &[String],
) -> Vec<usize> {
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
// Stable, so equals keep the system's order.
order.sort_by_key(|(_, c)| {
let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power), c.backend == Backend::Vulkan)
let (drives_display, drives_primary) = c.drives(displays);
(
unnamed,
c.device_type == DeviceType::Cpu,
c.backend == Backend::Gl,
!c.composites(compositor),
!drives_display,
!drives_primary,
power_rank(c.device_type, power),
c.backend == Backend::Vulkan,
)
});
order.into_iter().map(|(i, _)| i).collect()
}
@@ -133,6 +355,8 @@ pub struct Startup {
adapter: OnceLock<String>,
/// The UI's context, which counts the frames run.
ui: OnceLock<egui::Context>,
/// When the app was created, the files named at the start opened: the first frame is due.
ready: OnceLock<Instant>,
}
impl Startup {
@@ -141,23 +365,102 @@ impl Startup {
// Set once; the window is created once.
let _ = self.ui.set(ctx.clone());
}
/// The app is ready to draw its first frame.
pub fn ready(&self) {
// Set once, like `created`.
let _ = self.ready.set(Instant::now());
}
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, `WGPU_ADAPTER_NAME` and the
/// adapters a restart left out. Every adapter and the choice are logged, for bug reports.
pub fn selector(power: PowerPreference, startup: Arc<Startup>) -> NativeAdapterSelectorMethod {
/// What [`watch_first_frame`] makes of the window's start.
#[derive(Debug, PartialEq, Eq)]
enum FirstFrame {
/// Not due yet.
Wait,
/// On the screen (the second frame began, so the first was presented), or the window was
/// minimized at the start: nothing to watch.
Shown,
/// Overdue.
Hung,
}
/// The window's start after `frames` frames run, `since` its first frame was due.
fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
if frames >= 2 || minimized {
FirstFrame::Shown
} else if since >= FIRST_FRAME_TIMEOUT {
FirstFrame::Hung
} else {
FirstFrame::Wait
}
}
/// Watch the window's first frame from another thread: when it hasn't reached the screen
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
/// thread is stuck in the graphics driver.
pub fn watch_first_frame(startup: Arc<Startup>) {
let watch = move || loop {
std::thread::sleep(Duration::from_millis(250));
let Some(due) = startup.ready.get() else { continue };
let ui = startup.ui.get();
let frames = ui.map_or(0, egui::Context::cumulative_frame_nr);
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
match first_frame(frames, due.elapsed(), minimized) {
FirstFrame::Wait => continue,
FirstFrame::Shown => return,
FirstFrame::Hung => {}
}
let mut skip = skipped();
let Some(failed) = restart_without(true, frames, startup.adapter.get().map(String::as_str), &skip) else { return };
log::error!(
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
FIRST_FRAME_TIMEOUT.as_secs()
);
skip.push(failed.to_string());
match restart(&skip.join(",")) {
Ok(()) => std::process::exit(0),
Err(e) => {
log::error!("starting again failed: {e}");
return;
}
}
};
if let Err(e) = std::thread::Builder::new().name("first frame".into()).spawn(watch) {
log::warn!("no watch on the window's first frame: {e}");
}
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPU the `compositor`
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart
/// left out. Every adapter, the displays and the choice are logged: which GPU draws is the first
/// question in every black-window report.
pub fn selector(
power: PowerPreference,
displays: Vec<DisplayGpu>,
compositor: Option<(u32, u32)>,
startup: Arc<Startup>,
) -> NativeAdapterSelectorMethod {
let named = std::env::var(NAME_ENV).ok();
let skip = skipped();
Arc::new(move |adapters, surface| {
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
let order = adapter_order(&candidates, power, named.as_deref(), &skip);
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &skip);
let listed: Vec<String> = candidates
.iter()
.map(|c| {
let (drives_display, drives_primary) = c.drives(&displays);
let note = if !c.presents {
", can't show the window"
} else if skip.contains(&c.key) {
", failed before"
} else if c.composites(compositor) {
", the compositor draws with it"
} else if drives_primary {
", drives the primary display"
} else if drives_display {
", drives a display"
} else {
""
};
@@ -169,6 +472,14 @@ pub fn selector(power: PowerPreference, startup: Arc<Startup>) -> NativeAdapterS
let Some((adapter, chosen)) = first else {
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
};
let why = match chosen.drives(&displays) {
_ if chosen.composites(compositor) => "the compositor draws with it".to_string(),
(_, true) => "it drives the primary display".to_string(),
(true, false) => "it drives a display".to_string(),
(false, false) if displays.is_empty() => format!("power preference {power:?}"),
(false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"),
};
log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type);
// Set once; eframe picks the adapter once.
let _ = startup.adapter.set(chosen.key.clone());
Ok(adapter.clone())
@@ -240,13 +551,262 @@ mod tests {
use super::*;
fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate {
Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, presents: true }
Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true }
}
/// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that
/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing.
#[test]
fn the_surface_may_be_as_large_as_the_adapter_allows() {
assert_eq!(surface_limit(8192, 16384), 16384);
assert_eq!(surface_limit(8192, 4096), 8192);
}
fn names(c: &[Candidate], order: &[usize]) -> Vec<String> {
order.iter().map(|&i| c[i].name.clone()).collect()
}
const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
const WARP: (u32, u32) = (0x1414, 0x008c);
fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate {
Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true }
}
fn monitor(pci: (u32, u32)) -> DisplayGpu {
DisplayGpu { pci, primary: false }
}
fn primary(pci: (u32, u32)) -> DisplayGpu {
DisplayGpu { pci, primary: true }
}
/// The desktop of pdfcraft#378 as wgpu lists it on Windows: a Ryzen's integrated GPU without a
/// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and
/// OpenGL (which reports no device id).
fn ryzen_desktop() -> Vec<Candidate> {
vec![
pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090),
pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU),
pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090),
pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU),
pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP),
pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)),
]
}
/// pdfcraft#378: power saving picked the integrated GPU, whose driver reset when Windows had to
/// present its frames on the monitors of the NVIDIA card. The GPU that drives the displays now
/// comes first, through DX12 (#545); the integrated GPU stays the fallback a restart reaches.
#[test]
fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() {
let c = ryzen_desktop();
for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] {
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
}
// Without the display information the order is power saving's, as before.
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]), [3, 1, 2, 0, 5, 4]);
// After the NVIDIA adapters failed, the integrated GPU is next.
let skip = vec![c[2].key.clone(), c[0].key.clone()];
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &skip), [3, 1, 5, 4]);
}
/// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the
/// discrete one. Power saving and the display agree, in either enumeration order.
#[test]
fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() {
let c = ryzen_desktop();
for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] {
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}");
}
// Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the
// system's order among them (Mesa puts the compositor's GPU first), and a GPU without one
// comes after both.
let linux = vec![
pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU),
pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090),
pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU),
pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)),
];
let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)];
assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, None, &[]), [0, 1, 2, 3]);
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, None, &[]), [2, 0, 1, 3]);
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, None, &[]), [0, 1, 2, 3]);
}
/// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
/// `AQ_DRM_DEVICES` naming it first), as Vulkan lists it there: the integrated GPU drives the
/// built-in panel, yet the compositor shows nothing it draws, and reports no error. The GPU the
/// compositor draws with comes first; without it, the panel's, as before.
#[test]
fn the_gpu_the_compositor_draws_with_comes_before_the_panels() {
const RTX_3050: (u32, u32) = (0x10de, 0x25ac);
const INTEL_RPL: (u32, u32) = (0x8086, 0xa7a8);
let mut c = vec![
pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU", Backend::Vulkan, DeviceType::DiscreteGpu, RTX_3050),
pci_gpu("Intel(R) Graphics (RPL-P)", Backend::Vulkan, DeviceType::IntegratedGpu, INTEL_RPL),
pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU/PCIe/SSE2", Backend::Gl, DeviceType::Other, (RTX_3050.0, 0)),
];
c[2].presents = false;
let panel = [primary(INTEL_RPL)];
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[]), [0, 1]);
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, None, None, &[]), [1, 0]);
// The usual hybrid laptop, with the compositor on the integrated GPU: the panel's GPU.
assert_eq!(adapter_order(&c, PowerPreference::None, &[primary(INTEL_RPL), monitor(RTX_3050)], Some(INTEL_RPL), None, &[]), [1, 0]);
// `WGPU_ADAPTER_NAME` still comes first, and a restart after a failure still moves on.
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), Some("intel"), &[]), [1, 0]);
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[c[0].key.clone()]), [1]);
// A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some((0x1af4, 0x1050)), None, &[]), [1, 0]);
}
/// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so
/// its GPU draws, whichever kind it is.
#[test]
fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() {
let c = ryzen_desktop();
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, None, &[]), [2, 0, 3, 1, 5, 4]);
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, None, &[]), [3, 1, 2, 0, 5, 4]);
// The displays only matter among adapters that can show the window; a GPU driving no
// display still loses to one that does.
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, None, &[]), [3, 1, 2, 0, 5, 4]);
}
/// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id,
/// Metal's zeros) change nothing: power saving's order, the old behaviour.
#[test]
fn displays_no_adapter_matches_leave_the_power_preference_in_charge() {
let c = ryzen_desktop();
let low = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]);
for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] {
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), low, "{displays:?}");
}
assert_eq!(
names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, None, &[]))[0],
"NVIDIA GeForce RTX 3090"
);
// The #502 machine's adapters report no ids in these tests: the primary display never matches.
let c = issue_502();
assert_eq!(
adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, None, &[]),
adapter_order(&c, PowerPreference::None, &[], None, None, &[])
);
assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty());
}
/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays.
#[test]
fn a_named_adapter_comes_before_the_one_driving_the_display() {
let mut c = ryzen_desktop();
let displays = [primary(NVIDIA_3090)];
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]);
c[2].presents = false;
c[0].presents = false;
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 5, 4]);
}
/// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic
/// looks at the displays.
#[test]
fn only_automatic_considers_which_gpu_drives_the_display() {
assert!(automatic(None, None));
assert!(automatic(Some("automatic"), None));
assert!(automatic(Some("powerSaving"), None));
assert!(!automatic(Some("lowPower"), None));
assert!(!automatic(Some("highPerformance"), None));
assert!(!automatic(None, Some(PowerPreference::None)));
assert!(!automatic(Some("automatic"), Some(PowerPreference::LowPower)));
assert!(preferred_displays(Some("lowPower"), None).is_empty());
assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty());
// Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop).
assert_eq!(preferred_displays(None, None), display_gpus());
assert_eq!(preferred_compositor(Some("lowPower"), None), None);
assert_eq!(preferred_compositor(None, Some(PowerPreference::HighPerformance)), None);
assert_eq!(preferred_compositor(None, None), crate::compositor_gpu::compositor_gpu());
}
#[test]
fn pci_ids_come_from_windows_device_ids() {
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
// Remote Desktop and other non-PCI display devices, and malformed ids.
assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None);
assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
assert_eq!(pci_ids("VEN_&DEV_"), None);
assert_eq!(pci_ids(""), None);
}
/// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary
/// display, and none is listed twice. (A headless CI runner may list none.)
#[cfg(windows)]
#[test]
fn windows_lists_each_gpu_with_a_monitor_once() {
let gpus = windows_display_gpus();
assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
for (i, g) in gpus.iter().enumerate() {
assert_ne!(g.pci.0, 0, "{gpus:?}");
assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
}
}
#[test]
fn internal_panels_are_edp_lvds_and_dsi_connectors() {
for name in ["eDP-1", "LVDS-1", "DSI-1"] {
assert!(is_internal_panel(name), "{name}");
}
for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
assert!(!is_internal_panel(name), "{name}");
}
}
/// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port
/// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without
/// PCI ids. Each GPU is listed once, the one with the panel as primary.
#[test]
fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
std::fs::create_dir_all(dir.join(name).join("device"))?;
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
};
let connector = |name: &str, status: &str| -> std::io::Result<()> {
std::fs::create_dir_all(dir.join(name))?;
std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
};
card("card0", AMD_IGPU)?;
card("card1", NVIDIA_3090)?;
std::fs::create_dir_all(dir.join("card2"))?;
connector("card0-eDP-1", "connected")?;
connector("card0-HDMI-A-1", "disconnected")?;
connector("card1-DP-1", "connected")?;
connector("card1-DP-2", "connected")?;
connector("card2-Virtual-1", "connected")?;
std::fs::write(dir.join("renderD128"), "")?;
let mut gpus = linux_display_gpus(&dir);
gpus.sort_by_key(|g| g.pci);
assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]);
assert!(linux_display_gpus(&dir.join("no such dir")).is_empty());
std::fs::remove_dir_all(&dir)
}
#[test]
fn display_gpus_are_logged_as_pci_ids() {
assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)");
assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e");
let mut gpus = vec![];
add_display_gpu(&mut gpus, NVIDIA_3090, false);
add_display_gpu(&mut gpus, NVIDIA_3090, true);
add_display_gpu(&mut gpus, AMD_IGPU, false);
assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]);
}
/// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's
/// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software
/// renderer, and OpenGL.
@@ -262,7 +822,7 @@ mod tests {
#[test]
fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() {
let c = issue_502();
let order = adapter_order(&c, power_preference(None, None), None, &[]);
let order = adapter_order(&c, power_preference(None, None), &[], None, None, &[]);
let first = &c[order[0]].name;
if cfg!(any(windows, target_os = "macos")) {
assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
@@ -275,7 +835,7 @@ mod tests {
#[test]
fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() {
let c = issue_502();
let order = |power| names(&c, &adapter_order(&c, power, None, &[]));
let order = |power| names(&c, &adapter_order(&c, power, &[], None, None, &[]));
assert_eq!(
order(PowerPreference::None),
[
@@ -307,12 +867,52 @@ mod tests {
/// A restart leaves out the adapter the window failed on, so Power Saving on the machine of
/// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try.
/// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still
/// differ, so when the integrated GPU fails, the restart renders on the discrete one.
#[test]
fn gpus_without_pci_ids_are_told_apart_by_name() {
let mac = |name: &str, device_type| Candidate {
key: key(Backend::Metal, 0, 0, name),
name: name.into(),
backend: Backend::Metal,
device_type,
pci: (0, 0),
presents: true,
};
let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)];
assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics"));
let first = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]);
assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics");
let skip = parse_skip(&c[first[0]].key);
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip)), ["NVIDIA GeForce GT 750M"]);
// A comma in a name can't split the list.
assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]);
}
#[test]
fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() {
let s = Duration::from_secs;
// The second frame began: the first was presented.
assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown);
// Still drawing (or presenting) the first frame: waited for, then given up on (#964).
for frames in [0, 1] {
assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait);
assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung);
}
// A window minimized at the start may not draw again until restored.
assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown);
// A hung adapter is left out once, like one that failed.
let skip = vec!["Dx12:10de:28a0".to_string()];
assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None);
assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8"));
}
#[test]
fn a_restart_tries_the_next_adapter() {
let c = issue_502();
let mut skip = vec![];
let mut tried = vec![];
while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, None, &skip).first() {
while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip).first() {
tried.push(c[i].name.clone());
skip.push(c[i].key.clone());
}
@@ -333,33 +933,36 @@ mod tests {
c[0].presents = false;
c[3].presents = false;
for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] {
let order = adapter_order(&c, power, None, &[]);
let order = adapter_order(&c, power, &[], None, None, &[]);
assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}");
assert_eq!(order.len(), 2);
}
for p in &mut c {
p.presents = false;
}
assert!(adapter_order(&c, PowerPreference::None, None, &[]).is_empty());
assert!(adapter_order(&[], PowerPreference::HighPerformance, None, &[]).is_empty());
assert!(adapter_order(&c, PowerPreference::None, &[], None, None, &[]).is_empty());
assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, None, &[]).is_empty());
}
#[test]
fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() {
let c = issue_502();
let order = adapter_order(&c, PowerPreference::HighPerformance, Some("radv"), &[]);
let order = adapter_order(&c, PowerPreference::HighPerformance, &[], None, Some("radv"), &[]);
assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
assert_eq!(order.len(), 4);
// Any case; every match comes before the rest, natively first.
let order = adapter_order(&c, PowerPreference::LowPower, Some("GeForce"), &[]);
let order = adapter_order(&c, PowerPreference::LowPower, &[], None, Some("GeForce"), &[]);
assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]);
// An empty name or one nothing matches changes nothing.
for named in ["", "no such gpu"] {
assert_eq!(adapter_order(&c, PowerPreference::LowPower, Some(named), &[]), adapter_order(&c, PowerPreference::LowPower, None, &[]));
assert_eq!(
adapter_order(&c, PowerPreference::LowPower, &[], None, Some(named), &[]),
adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[])
);
}
// A named adapter that failed is left out like any other.
let skip = vec![c[1].key.clone()];
assert_eq!(adapter_order(&c, PowerPreference::None, Some("radv"), &skip)[0], 0);
assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, Some("radv"), &skip)[0], 0);
}
/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
@@ -373,9 +976,9 @@ mod tests {
gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
];
let order = adapter_order(&c, PowerPreference::LowPower, None, &[]);
let order = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]);
assert_eq!(order, [3, 1, 2, 0, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [2, 0, 3, 1, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, None, &[]), [2, 0, 3, 1, 4]);
}
/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black.
@@ -390,9 +993,21 @@ mod tests {
gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other),
];
for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] {
assert_eq!(adapter_order(&c, power, None, &[]), [1, 0, 3, 2], "{power:?}");
assert_eq!(adapter_order(&c, power, &[], None, None, &[]), [1, 0, 3, 2], "{power:?}");
}
assert_eq!(adapter_order(&c, PowerPreference::LowPower, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
}
#[test]
fn windows_never_loads_vulkan_drivers_unless_asked() {
let all = Backends::all();
let start = backends(all, false);
if cfg!(windows) {
assert_eq!(start, Backends::DX12 | Backends::GL, "#806");
} else {
assert_eq!(start, all);
}
assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
}
#[test]
@@ -433,8 +1048,8 @@ mod tests {
fn skipped_adapters_parse_from_a_comma_separated_list() {
assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]);
assert!(parse_skip("").is_empty());
assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e), "Vulkan:1002:164e");
assert_eq!(key(Backend::Gl, 0x10de, 0x2f04), "Gl:10de:2f04");
assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e");
assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04");
}
#[test]
+84
View File
@@ -0,0 +1,84 @@
//! macOS: the font files CoreText's font manager lists, scanned with the font folders
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
//! MCP) both install it; the CLI shares this file.
//!
//! Apps and font managers can register fonts with the font manager from files in any folder,
//! outside the font folders
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl(_:_:_:)>).
//! The font manager's list of available fonts holds them
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagercopyavailablefonturls()>), so
//! VectorCraft lists them as it does on Windows (`system_fonts.rs`), reading the files in place
//! (#579).
//!
//! CoreText is a C API with no safe binding, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use std::collections::BTreeSet;
use objc2_core_foundation::{CFType, CFURL};
use objc2_core_text::CTFontManagerCopyAvailableFontURLs;
/// The most fonts read from the list (a system has a few thousand faces).
const MAX_FONTS: usize = 1 << 16;
/// Have the font scans read the font files CoreText lists.
pub fn install() {
vectorcraft_text::set_platform_font_files(font_files);
}
/// The files of the fonts available to the app as CoreText lists them now (fonts registered since
/// the last call included).
fn font_files() -> Vec<String> {
// SAFETY: a CoreText call without arguments; it returns a new array.
let list = unsafe { CTFontManagerCopyAvailableFontURLs() };
// SAFETY: the array holds CF objects (CFURLs, as CoreText documents); each one is checked to be
// a CFURL before it is read.
let fonts = unsafe { list.cast_unchecked::<CFType>() };
// Faces of one file (a collection's) name it once.
let files: BTreeSet<String> = fonts
.iter()
.take(MAX_FONTS)
.filter_map(|f| f.downcast::<CFURL>().ok())
// Only file URLs, as in `open_documents.rs` (#433).
.filter(|u| u.scheme().is_some_and(|s| s.to_string().eq_ignore_ascii_case("file")))
.filter_map(|u| u.to_file_path()?.into_os_string().into_string().ok())
.collect();
files.into_iter().collect()
}
#[cfg(test)]
mod tests {
use objc2_core_foundation::CFURL;
use objc2_core_text::{CTFontManagerRegisterFontsForURL, CTFontManagerScope, CTFontManagerUnregisterFontsForURL};
/// CoreText lists the installed fonts, by their files' full paths.
#[test]
fn coretext_lists_the_installed_font_files() {
let files = super::font_files();
assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first());
assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts/")), "{} files, {:?}", files.len(), files.first());
}
/// A font registered with CoreText from another folder, as apps and font managers register
/// fonts, is listed while it is registered.
#[test]
fn fonts_registered_from_other_folders_are_listed() {
let dir = std::env::temp_dir().join(format!("vectorcraft-mac-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A space in the name: the URL's escapes are decoded. The bundled serif isn't a font macOS
// installs.
let file = dir.join("Registered Font.ttf");
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSerif4-Regular.ttf"), &file).unwrap();
let want = std::fs::canonicalize(&file).unwrap();
let listed = || super::font_files().iter().any(|f| f.ends_with("/Registered Font.ttf") && std::fs::canonicalize(f).is_ok_and(|p| p == want));
assert!(!listed());
let url = CFURL::from_file_path(&file).unwrap();
// SAFETY: a file URL, a scope constant and no error out-parameter.
assert!(unsafe { CTFontManagerRegisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) });
let registered = listed();
// SAFETY: as above.
unsafe { CTFontManagerUnregisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) };
std::fs::remove_dir_all(&dir).unwrap();
assert!(registered);
}
}
+122 -2
View File
@@ -12,16 +12,83 @@
//! - Hide and Hide Others are items of ours calling `NSApplication`: AppKit's own always take ⌘H
//! (View › Hide Edges) and ⌥⌘H.
use std::sync::Once;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use egui::{Key, KeyboardShortcut};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType, NSMenuDidBeginTrackingNotification, NSMenuDidEndTrackingNotification};
use objc2_foundation::{NSNotification, NSNotificationCenter};
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, Node, Standard};
// AppKit can deliver window input while its system menu is still tracking (especially with
// asynchronous menu presentation). Keep the native tracking state separate from egui popups.
static TRACKING: AtomicBool = AtomicBool::new(false);
fn observe_tracking(ctx: &egui::Context) {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
// SAFETY: AppKit initializes these immutable notification-name constants.
#[allow(unsafe_code)]
let notifications = unsafe { [(NSMenuDidBeginTrackingNotification, true), (NSMenuDidEndTrackingNotification, false)] };
for (name, tracking) in notifications {
let repaint = ctx.clone();
let block = RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
TRACKING.store(tracking, Ordering::Relaxed);
// Apply any deferred menu updates as soon as the tracking session ends.
if !tracking {
repaint.request_repaint();
}
});
// SAFETY: these are AppKit's notification names. The notification center retains
// the observer and block for the application's lifetime; callbacks update an atomic and request repaint.
#[allow(unsafe_code)]
unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(Some(name), None, None, &block);
}
}
});
}
fn cancel_tracking() {
if let Some(mtm) = MainThreadMarker::new()
&& let Some(menu) = NSApplication::sharedApplication(mtm).mainMenu()
{
menu.cancelTracking();
}
TRACKING.store(false, Ordering::Relaxed);
}
/// A dismissing click belongs to the menu, so the canvas and panel controls must not see it.
fn dismiss_input(raw: &mut egui::RawInput, tracking: bool) -> bool {
if !raw
.events
.iter()
.any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }))
{
return false;
}
if tracking {
raw.events.retain(|e| !matches!(e, egui::Event::PointerButton { .. } | egui::Event::Key { key: egui::Key::Escape, .. }));
}
true
}
/// Inspect real window input before native key equivalents and control events are added.
pub fn raw_input_hook(raw: &mut egui::RawInput) {
// Asynchronous menu presentation may leave a displayed menu after its tracking notification.
// Still cancel it, but consume the dismissing input only during a reported tracking session.
if dismiss_input(raw, TRACKING.load(Ordering::Relaxed)) {
cancel_tracking();
}
}
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
@@ -48,10 +115,13 @@ pub struct MacMenu {
entries: Vec<Entry>,
rx: Receiver<Chosen>,
structure: u64,
/// Latest menu state to apply after AppKit finishes tracking; item ids stay stable meanwhile.
pending: Option<MenuBar>,
}
impl MacMenu {
fn new(ctx: &egui::Context) -> MacMenu {
observe_tracking(ctx);
let (tx, rx) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
@@ -67,7 +137,7 @@ impl MacMenu {
// Wake egui, so the item runs now rather than on the next mouse move.
repaint.request_repaint();
}));
MacMenu { menu: None, entries: Vec::new(), rx, structure: 0 }
MacMenu { menu: None, entries: Vec::new(), rx, structure: 0, pending: None }
}
/// Build the menus of `bar` and make them the app's, in place of the ones before.
@@ -146,6 +216,13 @@ fn append(sub: &Submenu, nodes: &[Node], entries: &mut Vec<Entry>) -> muda::Resu
impl Backend for MacMenu {
fn sync(&mut self, bar: &MenuBar) {
// Replacing or mutating an NSMenu during tracking can strand its displayed menu. Keep
// the latest state: the shared model considers this sync complete and may not resend it.
if TRACKING.load(Ordering::Relaxed) {
self.pending = Some(bar.clone());
return;
}
self.pending = None;
let items = bar.items();
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure || items.len() != self.entries.len() {
if let Err(e) = self.build(bar) {
@@ -183,6 +260,7 @@ impl Backend for MacMenu {
fn drain(&mut self) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(chosen) = self.rx.try_recv() {
cancel_tracking();
// Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled.
let Some(e) = chosen.id.strip_prefix("vc").and_then(|n| n.parse::<usize>().ok()).and_then(|i| self.entries.get(i)) else { continue };
if let Some(command @ (native_menu::HIDE | native_menu::HIDE_OTHERS)) = e.item.command {
@@ -207,6 +285,12 @@ impl Backend for MacMenu {
None => Event::Click(e.item.clone()),
});
}
// Drain against the old item ids before a deferred structure change replaces them.
if !TRACKING.load(Ordering::Relaxed)
&& let Some(bar) = self.pending.take()
{
self.sync(&bar);
}
out
}
}
@@ -364,4 +448,40 @@ mod tests {
assert_ne!(parse("Cmd+M"), parse("Ctrl+Cmd+M"));
assert!(parse("Cmd+M").is_some());
}
fn press() -> egui::Event {
egui::Event::PointerButton {
pos: egui::pos2(300.0, 200.0),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
}
}
#[test]
fn outside_press_dismisses_without_starting_a_canvas_gesture() {
let moved = egui::Event::PointerMoved(egui::pos2(300.0, 200.0));
let mut raw = egui::RawInput { events: vec![moved.clone(), press()], ..Default::default() };
assert!(dismiss_input(&mut raw, true));
assert_eq!(raw.events, [moved]);
assert!(!dismiss_input(&mut raw, true));
raw.events.push(press());
assert!(dismiss_input(&mut raw, false));
assert_eq!(raw.events.len(), 2, "ordinary canvas presses still reach egui");
}
#[test]
fn escape_dismisses_without_reaching_the_active_tool() {
let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
let mut raw = egui::RawInput { events: vec![escape(true), escape(false)], ..Default::default() };
assert!(dismiss_input(&mut raw, true));
assert!(raw.events.is_empty());
}
#[test]
fn moving_the_pointer_does_not_dismiss_a_tracking_menu() {
let mut raw = egui::RawInput { events: vec![egui::Event::PointerMoved(egui::pos2(300.0, 200.0))], ..Default::default() };
assert!(!dismiss_input(&mut raw, true));
assert_eq!(raw.events.len(), 1);
}
}
+137
View File
@@ -0,0 +1,137 @@
//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of
//! every filter but doesn't show their names. The accessory uses the same filters and updates
//! the filename, whose extension already determines the format in the save command.
//!
//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The
//! panel reference is weak, so closing the sheet releases both without a retain cycle.
//! Native view handles and Objective-C subclassing require the scoped unsafe allowance.
#![allow(unsafe_code)]
use std::path::Path;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some((_, extensions)) = self.ivars().filters.get(index) else { return };
let Some(extension) = extensions.first() else { return };
let name = panel.nameFieldStringValue().to_string();
let new = filename(&name, extension, &self.ivars().filters);
panel.setNameFieldStringValue(&NSString::from_str(&new));
}
}
);
/// The suggested file's type, or the request's default (the first filter).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = Path::new(name).extension().and_then(|e| e.to_str());
filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0)
}
/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name.
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return name.to_owned();
}
let path = Path::new(name);
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x))))
{
path.with_extension(extension).to_string_lossy().into_owned()
} else {
format!("{name}.{extension}")
}
}
/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests
/// without several named file types are left to rfd.
pub fn attach(window: &winit::window::Window, pick: &FilePick) {
let Some(mtm) = MainThreadMarker::new() else { return };
let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if filters.len() < 2 {
return;
}
let Ok(handle) = window.window_handle() else { return };
let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return };
// SAFETY: winit keeps its content view alive while this window reference is in use.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() else { return };
let selected = initial_format(&pick.name, &filters);
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] };
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0)));
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false);
for (name, _) in &accessory.ivars().filters {
popup.addItemWithTitle(&NSString::from_str(i18n::t(name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
// SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is
// open. The selector above has exactly the target/action signature expected by NSControl.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
accessory.addSubview(&label);
accessory.addSubview(&popup);
}
panel.setExtensionHidden(false);
panel.setAccessoryView(Some(&accessory));
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
#[test]
fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() {
for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] {
assert_eq!(initial_format(name, FILTERS), index);
}
}
#[test]
fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() {
for (name, expected) in
[("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")]
{
assert_eq!(filename(name, "svg", FILTERS), expected);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
//! macOS: the window buttons (close, minimize, zoom) centred on the app bar (#968).
//!
//! AppKit puts the buttons in a standard title bar, shorter than the app bar they sit over, so they
//! rode high on it. [`center_buttons`] moves them to the middle of the bar, inset from its left as
//! far as from its top, and makes the title bar as tall as the app bar so they stay clickable.
//! AppKit lays its title bar out again when the window resizes or leaves full screen, so this runs
//! every frame and moves the buttons only when they are out of place.
//!
//! Reaching the title bar's views from the window's content view has no safe API, hence the scoped
//! `unsafe_code` allowance.
#![allow(unsafe_code)]
use objc2::runtime::AnyObject;
use objc2_app_kit::{NSView, NSWindowButton, NSWindowStyleMask};
use objc2_foundation::NSPoint;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
/// The room the app bar keeps at its left for the buttons in AppKit's own place, before they are
/// centred (and in full screen, where AppKit shows them elsewhere): their width and a gap.
pub const DEFAULT_INSET: f32 = 78.0;
/// Differences below this (in points) leave a frame as it is, so a frame in place isn't set again.
const SLACK: f64 = 0.25;
/// Centre the window's buttons on an app bar `bar` egui points tall, at the window's `zoom` factor
/// (native points per egui point). Returns the room the app bar keeps clear at its left for them,
/// in egui points, or `None` when they aren't over the bar (full screen) or can't be found.
pub fn center_buttons(frame: &eframe::Frame, zoom: f32, bar: f32) -> Option<f32> {
let RawWindowHandle::AppKit(handle) = frame.window_handle().ok()?.as_raw() else {
return None;
};
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and eframe hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let window = view.window()?;
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
return None;
}
let close = window.standardWindowButton(NSWindowButton::CloseButton)?;
let minimize = window.standardWindowButton(NSWindowButton::MiniaturizeButton)?;
let zoom_button = window.standardWindowButton(NSWindowButton::ZoomButton)?;
// The buttons sit in the title bar view, inside the title bar container that sets its height.
// SAFETY: both views belong to the window, alive while it is; used right away.
let container = unsafe { close.superview().and_then(|titlebar| titlebar.superview()) }?;
// Another layout (a later macOS) is left as AppKit made it.
if AnyObject::class(&container).name() != c"NSTitlebarContainerView" {
return None;
}
let size = close.frame().size;
let gap = minimize.frame().origin.x - (close.frame().origin.x + size.width);
let height = f64::from(bar * zoom);
let margin = (height - size.height) / 2.0;
if !(margin.is_finite() && margin > 0.0 && gap.is_finite() && gap >= 0.0 && zoom > 0.0) {
return None;
}
let mut bounds = container.frame();
let top = window.frame().size.height - height;
if (bounds.size.height - height).abs() > SLACK || (bounds.origin.y - top).abs() > SLACK {
bounds.size.height = height;
bounds.origin.y = top;
container.setFrame(bounds);
}
let mut x = margin;
for button in [close, minimize, zoom_button] {
let at = NSPoint::new(x, (height - button.frame().size.height) / 2.0);
let now = button.frame().origin;
if (now.x - at.x).abs() > SLACK || (now.y - at.y).abs() > SLACK {
button.setFrameOrigin(at);
}
x += size.width + gap;
}
// The last button's right edge (`x` steps one gap past it) and the margin again after it.
Some(((x - gap + margin) / f64::from(zoom)) as f32)
}
+579 -98
View File
@@ -1,13 +1,18 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port>] [--in-window-menus] [files…]`
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
//! [--in-window-menus] [files…]`
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`; a port from 1 to 65535, or the app doesn't
//! start) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods.
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
//! files its requests read and write (`vectorcraft_engine::file_access`); the person at the
//! keyboard isn't confined.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
@@ -17,21 +22,36 @@ compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinar
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
compile_error!("the win7 target requires --no-default-features --features windows7");
mod automation_args;
mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
mod control_server;
#[cfg(feature = "wgpu")]
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod window;
mod xwayland;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::picks::PickRequest;
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
struct App {
@@ -40,10 +60,53 @@ struct App {
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
graphics_lost: bool,
/// Frames the UI has run (see [`end_before_teardown`]).
frames: u64,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Each frame runs unconfined: it is the person at the keyboard's. The app confines what the
// control channel asks for, and the frames carrying input it injected, to the automation roots
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
file_access::unconfined(|| self.logic_frame(ctx, frame));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
file_access::unconfined(|| self.app.raw_input_hook(raw));
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
file_access::unconfined(|| save_prefs(&self.app));
}
}
impl App {
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.frames = ctx.cumulative_frame_nr();
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
self.graphics_lost = self.app.graphics_lost(&why);
@@ -59,41 +122,46 @@ impl eframe::App for App {
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
#[cfg(target_os = "macos")]
open_files(&mut self.app, open_documents::take());
if !self.app.file_dialog_open() {
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
if !self.app.file_dialog_open() {
open_files(&mut self.app, open_documents::take());
}
}
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
save_prefs(&self.app);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
save_prefs(&self.app);
}
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
/// failure then starts the app again on another adapter (#651).
#[allow(unused_variables)]
fn end_before_teardown(frames: u64) {
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
if frames >= gpu::STARTUP_FRAMES {
log::info!("quitting");
log::logger().flush();
std::process::exit(0);
}
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported in the status bar and on stderr; the others still open.
/// can't be opened is reported to the user and on stderr; the others still open.
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
for f in files {
if let Err(e) = vectorcraft_ui_egui::io::open_path(app, &f) {
if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) {
eprintln!("vectorcraft: {f}: {e}");
app.status(format!("Couldn't open {}: {e}", fileio::file_name(&f)));
}
}
}
@@ -112,27 +180,13 @@ fn discard_marked_text() {
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
fn prefs_path() -> Option<std::path::PathBuf> {
prefs_path_for("VectorCraft", "vectorcraft")
prefs_dir::prefs_path_for("VectorCraft", "vectorcraft")
}
/// The same place under the project's former name (DrawCraft): read once if there are no
/// VectorCraft preferences yet, so settings survive the rename.
fn legacy_prefs_path() -> Option<std::path::PathBuf> {
prefs_path_for("DrawCraft", "drawcraft")
}
fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join(lower))
};
base.map(|b| b.join("ui.json"))
prefs_dir::prefs_path_for("DrawCraft", "drawcraft")
}
/// Where the log files live: `logs` in the preferences folder (see `logging`).
@@ -151,7 +205,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
serde_json::from_slice(&bytes).ok()
decode_saved_prefs(&bytes)
}
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
use serde_json::{Map, Value, json};
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
if let Some(layout) = object.get("docking")
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
{
object.remove("docking");
}
if let Some(hidden) = object.get_mut(hidden_key) {
if let Value::Object(entries) = hidden {
entries.retain(|panel, location| {
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
});
} else {
object.remove(hidden_key);
}
}
}
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
serde_json::from_value(value).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
@@ -179,14 +268,107 @@ fn save_prefs(app: &VectorcraftApp) {
}
}
/// A native file dialog showing `pick`'s file types, folder and suggested name.
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// What rfd's blocking and asynchronous file dialogs are both set up with.
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[&str]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
macro_rules! dialog_settings {
($($dialog:ty),*) => {$(
impl DialogSettings for $dialog {
fn parent(self, window: &winit::window::Window) -> Self {
self.set_parent(window)
}
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[&str]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
self.set_directory(folder)
}
fn file_name(self, name: &str) -> Self {
self.set_file_name(name)
}
}
)*};
}
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => {
return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
});
}
PickRequest::Folder => return d,
};
let d = pick
.filters
.iter()
.fold(d, |d, (name, exts)| d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts));
let d = match &pick.folder {
Some(folder) => d.set_directory(folder),
Some(folder) => d.folder(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
}
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
/// window and keeps it in front).
#[cfg(not(target_os = "macos"))]
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
match parent {
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
None => dialog_for(rfd::FileDialog::new(), request),
}
}
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
fn make_folder(request: &PickRequest) {
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
let _ = std::fs::create_dir_all(folder);
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
#[cfg(not(target_os = "macos"))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
make_folder(request);
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
#[cfg(target_os = "macos")]
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
Vec::new()
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
@@ -224,29 +406,106 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_open_multi: Some(Box::new(|| {
fileio::place_filters()
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_save: Some(Box::new(|pick: &FilePick| {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
#[cfg(not(target_os = "macos"))]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request));
ui.request_repaint();
})
.ok()?;
Some(rx)
}
/// The paths a dialog shown as a sheet picks, once it closes.
#[cfg(target_os = "macos")]
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
/// returns at once and the window keeps running; a thread of its own waits for the answer.
#[cfg(target_os = "macos")]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
use winit::raw_window_handle::HasWindowHandle as _;
// rfd runs the panel modally after all unless it has the running app's window to hold the
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
let mtm = objc2::MainThreadMarker::new();
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
return None;
};
// The thread first, so the answer of a sheet once shown always has somewhere to go.
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(pollster::block_on(answer));
ui.request_repaint();
}
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
})),
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
PickRequest::Open(_) => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::OpenMany => {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(pick) => {
let picked = dialog.save_file();
mac_save_format::attach(window, &pick);
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
};
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
let _ = sheet_tx.send(answer);
Some(rx)
}
/// The host's services; `ui` is woken when a file dialog answers.
fn services(parent: Parent, ui: egui::Context) -> Services {
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
})),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
@@ -260,7 +519,7 @@ fn services() -> Services {
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
@@ -296,6 +555,18 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the
/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too.
#[cfg(windows)]
fn set_taskbar_icon(w: &winit::window::Window) {
use winit::platform::windows::WindowExtWindows as _;
let icon = app_icon();
// An icon that can't be made leaves the generic one: nothing else depends on it.
if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
w.set_taskbar_icon(Some(i));
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
@@ -306,17 +577,52 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
/// macOS keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
/// before the window opens; a missing or unreadable value keeps the default.
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
}
}
fn main() -> std::process::ExitCode {
// First, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control = automation_args::ControlArg::default();
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut roots_args =
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
}
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
@@ -325,6 +631,28 @@ fn main() -> std::process::ExitCode {
_ => files.push(a),
}
}
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
// After the arguments: `--control` and `--version` win over a bad environment value.
let control_port = match control.port {
Some(port) => Some(port),
None => match automation_args::env_control_port(std::env::var_os("VECTORCRAFT_CONTROL_PORT")) {
Ok(port) => port,
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
},
};
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
prefs_dir::install_fonts();
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
@@ -339,29 +667,70 @@ fn main() -> std::process::ExitCode {
None => logger.no_file(),
}
}
// The roots confine the control channel: a bad one keeps the app from starting rather than
// leaving agents unconfined. Without a control port there is nothing to confine.
let roots = if control_port.is_none() {
if read_root.is_some() || write_root.is_some() {
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
}
None
} else {
match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) {
Ok(roots) => roots,
Err(e) => {
log::error!("{e}");
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
}
};
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
#[cfg(feature = "wgpu")]
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
#[cfg(feature = "wgpu")]
let power = gpu::power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
let power_env = eframe::wgpu::PowerPreference::from_env();
#[cfg(feature = "wgpu")]
let power = gpu::power_preference(gpu_pref, power_env);
// Automatic draws on the GPU that drives the (primary) display: a GPU without a monitor reset
// its driver and took every monitor down (pdfcraft#378). Logged first: the first question in
// every black-window report.
#[cfg(feature = "wgpu")]
let displays = gpu::preferred_displays(gpu_pref, power_env);
// Under Wayland, the GPU the compositor draws with comes even before those (src/compositor_gpu.rs).
#[cfg(feature = "wgpu")]
let compositor = gpu::preferred_compositor(gpu_pref, power_env);
#[cfg(feature = "wgpu")]
let chosen_gpu = !gpu::automatic(gpu_pref, power_env);
#[cfg(feature = "wgpu")]
if !chosen_gpu {
let listed: Vec<String> = displays.iter().map(ToString::to_string).collect();
log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") });
} else {
log::info!("graphics processor chosen by the user ({power:?}): which GPU drives the display isn't considered");
}
#[cfg(feature = "wgpu")]
let startup = std::sync::Arc::new(gpu::Startup::default());
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
if xwayland::x11_libraries_found() {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
}));
} else {
log::warn!("Linux Display Server is X11, but the X11 libraries aren't all installed: opening the window on Wayland");
}
}
options
};
#[cfg(feature = "wgpu")]
let options = {
@@ -371,12 +740,35 @@ fn main() -> std::process::ExitCode {
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.native_adapter_selector = Some(gpu::selector(power, startup.clone()));
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, startup.clone()));
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends = gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some());
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
create.instance_descriptor.backend_options.dx12.shader_compiler =
eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc);
// The window's surface may be as large as the adapter allows, not eframe's fixed
// 8192 px: a window stretched over several monitors, or grown by a display scale
// change, otherwise fails to configure its surface and the app closes (#1459).
let device = create.device_descriptor.clone();
create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| {
let mut descriptor = device(adapter);
let limit = &mut descriptor.required_limits.max_texture_dimension_2d;
*limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d);
descriptor
});
}
options
};
#[cfg(feature = "wgpu")]
gpu::watch_panics();
#[cfg(feature = "wgpu")]
gpu::watch_first_frame(startup.clone());
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
@@ -388,17 +780,25 @@ fn main() -> std::process::ExitCode {
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
#[cfg(windows)]
set_taskbar_icon(w);
}
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// So do the Libraries panel's libraries; runs without preferences (agents' test
// runs) keep theirs for the session only, never touching the user's.
if prefs_enabled() {
let libraries = prefs_path().and_then(|p| Some(p.parent()?.join("Libraries").to_string_lossy().to_string()));
app.session.libraries.set_dir(libraries);
}
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
@@ -412,7 +812,15 @@ fn main() -> std::process::ExitCode {
log::info!("rendering with {summary} (power preference {power:?})");
created.created(&cc.egui_ctx);
if !gpu::skipped().is_empty() {
app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}"));
// A processor chosen in Settings can be the one that can't show the window.
let hint = if chosen_gpu {
" (Settings › Performance › Graphics Processor › Automatic uses the one that drives your display)"
} else {
""
};
app.status(format!(
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
));
}
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
@@ -422,11 +830,16 @@ fn main() -> std::process::ExitCode {
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
app.custom_titlebar = custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
app = app.with_control(rx).with_automation_roots(roots.clone());
if let Some(roots) = &roots {
// Everywhere the app doesn't say whose work it is (worker threads), the
// roots hold: unconfined is only what the person at the keyboard does.
file_access::confine_process(roots.clone());
log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root());
}
}
#[cfg(target_os = "macos")]
{
@@ -438,8 +851,11 @@ fn main() -> std::process::ExitCode {
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
open_files(&mut app, files);
Ok(Box::new(App { app, graphics_loss, graphics_lost: false }))
file_access::unconfined(|| open_files(&mut app, files));
// The first frame is due from now (`gpu::watch_first_frame`).
#[cfg(feature = "wgpu")]
created.ready();
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
}),
)
});
@@ -460,6 +876,70 @@ fn main() -> std::process::ExitCode {
mod tests {
use super::*;
fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
#[test]
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
use serde_json::json;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
app.ui.toolbar = false;
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
let mut raw = original.clone();
raw["docking"] = invalid_layout.clone();
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
assert!(!loaded.toolbar);
assert!(loaded.docking.is_none());
assert!(!loaded.docking_hidden.contains_key("layers"));
if invalid_hidden.is_object() {
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
} else {
assert!(loaded.docking_hidden.is_empty());
}
assert_eq!(loaded.custom_workspaces.len(), 2);
assert_eq!(loaded.custom_workspaces[0].name, "Future");
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
assert!(
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
"unrelated fields retain normal strict parsing"
);
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// Preferences › User Interface › System Title Bar gives the window back its system
// decorations on Windows and Linux; macOS always has them.
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
assert!(!super::custom_titlebar(Some(&on)));
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
if super::CUSTOM_TITLEBAR {
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
@@ -471,13 +951,14 @@ mod tests {
.collect()
}
/// Finder offers the app for every file File › Open reads (#295, #354), takes over no other
/// app's files, and hands them to the app rather than to AppKit's document machinery.
/// Finder offers the app for every file File › Open reads (#295, #354) but Photoshop documents
/// (`UNASSOCIATED_EXTS`), takes over no other app's files, and hands them to the app rather than
/// to AppKit's document machinery.
#[test]
fn the_macos_bundle_opens_every_readable_format() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let declared = plist_extensions(plist);
for e in fileio::OPEN_EXTS {
for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) {
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
}
for e in &declared {
+39
View File
@@ -0,0 +1,39 @@
//! Where VectorCraft keeps its settings, `ui.json`, and next to it folders of the user's own
//! (Swatches, Graphic Styles, Fonts, Data Recovery, logs). The desktop app and `vectorcraft-cli`
//! (which shares this file) read the same Fonts folder.
use std::path::PathBuf;
#[path = "profile.rs"]
mod profile;
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
/// BSD).
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
let legacy = name == "DrawCraft" || lower == "drawcraft";
profile::prefs_path(legacy)
}
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
/// `text.addFontFiles` copies font files into. The folder is set whether or not
/// `VECTORCRAFT_NO_PREFS` is set, as the Swatches and Graphic Styles folders are.
pub fn install_fonts() {
if let Some(dir) = prefs_path_for("VectorCraft", "vectorcraft").and_then(|p| Some(p.parent()?.join("Fonts"))) {
vectorcraft_text::set_app_font_dir(dir);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_font_scans_read_the_fonts_folder_next_to_the_settings() {
install_fonts();
let fonts = prefs_path_for("VectorCraft", "vectorcraft").and_then(|p| Some(p.parent()?.join("Fonts")));
assert!(fonts.is_some(), "a home folder to keep the settings in");
assert_eq!(vectorcraft_text::app_font_dir().map(std::path::Path::to_path_buf), fonts);
assert!(vectorcraft_text::system_font_dirs().contains(&fonts.unwrap_or_default()));
}
}
+27 -179
View File
@@ -1,127 +1,54 @@
//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and
//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's
//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of
//! the PDF viewer.
use std::path::PathBuf;
//! Desktop printer transport. VectorCraft composes every page and copy before submission;
//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because
//! Windows.Data.Pdf is unavailable there.
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
/// The system's printing.
pub struct SystemPrint;
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
mod windows7;
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
use windows7 as system;
pub struct SystemPrint;
impl PrintService for SystemPrint {
fn printers(&mut self) -> Vec<Printer> {
system::printers()
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let r = system::print(job, &file);
let result = system::print(job);
let to = job.printer.unwrap_or("the default printer");
r.map(|note| format!("Sent “{}” to {to}{note}", job.title))
result.map(|note| format!("Sent “{}” to {to}{note}", job.title))
}
fn shared_print(&self) -> Option<vectorcraft_ui_egui::print::SharedPrintFn> {
Some(std::sync::Arc::new(|job| SystemPrint.print(job)))
}
fn has_setup(&self) -> bool {
true
}
fn setup(&mut self, printer: Option<&str>) -> Result<(), String> {
system::setup(printer)
}
}
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?;
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line).
#[cfg(any(test, not(windows)))]
fn lpstat_names(text: &str) -> Vec<String> {
let mut names: Vec<String> = vec![];
for name in text.lines().filter_map(|l| l.split_whitespace().next()) {
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system
/// default destination").
#[cfg(any(test, not(windows)))]
fn lpstat_default(text: &str) -> Option<String> {
let line = text.lines().find(|l| l.contains(':'))?;
let name = line.rsplit_once(':')?.1.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
#[cfg(any(test, windows))]
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
#[cfg(any(test, windows))]
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
#[cfg(not(windows))]
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
mod system {
use super::*;
pub fn printers() -> Vec<Printer> {
// `-e` lists every destination (CUPS 2); older systems answer `-a`.
let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t));
let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t));
names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect()
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
let mut c = Command::new("lp");
if let Some(p) = job.printer {
c.args(["-d", p]);
}
c.args(["-t", job.title]).arg(file);
let r = output(&mut c);
// `lp` has spooled its own copy (or failed).
let _ = std::fs::remove_file(file);
r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default())
pub fn print(job: &PrintJob) -> Result<String, String> {
// Copies, layout, marks and colour management are already in the PDF. Preserve its
// physical sheet size and the queue's duplex default without printing copies twice.
let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() };
craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string())
}
pub fn setup(printer: Option<&str>) -> Result<(), String> {
#[cfg(windows)]
{
let _ = printer;
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
}
#[cfg(target_os = "macos")]
{
let _ = printer;
@@ -131,92 +58,13 @@ mod system {
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(windows, target_os = "macos")))]
{
if Command::new("system-config-printer").spawn().is_ok() {
return Ok(());
}
// CUPS' own pages.
let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}"));
Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
}
#[cfg(windows)]
mod system {
use super::*;
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
#[cfg(feature = "windows7")]
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
#[cfg(not(feature = "windows7"))]
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
#[cfg(feature = "windows7")]
let mut command = {
let mut command = Command::new("control.exe");
command.arg("printers");
command
};
#[cfg(not(feature = "windows7"))]
let mut command = {
let mut command = Command::new("explorer");
command.arg("ms-settings:printers");
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lpstat_lists_names_and_the_default() {
let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n");
assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]);
assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]);
assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet"));
assert_eq!(lpstat_default("no system default destination\n"), None);
}
#[test]
fn powershell_lines_become_printers() {
let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n");
assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
let _ = std::fs::remove_file(path);
}
}
+129
View File
@@ -0,0 +1,129 @@
//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent.
use std::path::PathBuf;
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, Printer};
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
// The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never
// truncate an existing path, and remove incomplete files or refused launches immediately.
use std::io::Write as _;
let mut file =
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?;
if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) {
drop(file);
let _ = std::fs::remove_file(&path);
return Err(format!("can't write {}: {e}", path.display()));
}
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new(
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe"),
);
c.args([
"-NoProfile",
"-NonInteractive",
"-Command",
&format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"),
])
.creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
fn submit(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
let mut command = {
let mut command = Command::new("control.exe");
command.arg("printers");
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
pub fn print(job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let result = submit(job, &file);
if result.is_err() {
// No viewer accepted the launch, so no asynchronous reader needs this document.
let _ = std::fs::remove_file(file);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn powershell_lines_and_quotes_are_preserved() {
assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf_for_the_async_viewer() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
std::fs::remove_file(path).unwrap();
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
use std::ffi::OsString;
use std::path::PathBuf;
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
}
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
}
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
let base = match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
};
base.map(|p| p.join("ui.json"))
}
#[cfg(test)]
mod tests {
use super::*;
fn environment(key: &str) -> Option<OsString> {
match key {
"HOME" => Some("/user".into()),
"APPDATA" => Some("/roaming".into()),
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
_ => None,
}
}
#[test]
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
for os in ["macos", "windows", "linux"] {
let env = |key: &str| {
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
};
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env), None);
}
}
#[test]
fn empty_override_does_not_fall_back_to_user_files() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
assert_eq!(prefs_path_with("macos", false, env), None);
assert_eq!(prefs_path_with("macos", true, env), None);
}
#[test]
fn unset_override_keeps_platform_paths_and_legacy_migration() {
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
Some("/user/.config/drawcraft/ui.json".into())
);
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Windows: the font files in DirectWrite's system font collection, scanned with the font folders
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
//! MCP) both install it; the CLI shares this file.
//!
//! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their
//! own folders, without installing them: the font folders and the registry don't list them. Since
//! Windows 10 (version 1607) DirectWrite's system font collection holds them too
//! (<https://learn.microsoft.com/windows/win32/directwrite/what-s-new-in-directwrite-for-windows-8-consumer-preview>,
//! "Support for Adobe Typekit and other font-service clients"), so VectorCraft lists them as other
//! apps do, reading the files in place (#579).
//!
//! DirectWrite is COM, which has no safe API, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use std::collections::BTreeSet;
use windows::Win32::Graphics::DirectWrite::{
DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory3, IDWriteFontSet, IDWriteLocalFontFileLoader,
};
use windows::core::Interface;
/// The most fonts read from the collection (a system has a few thousand).
const MAX_FONTS: u32 = 1 << 16;
/// The longest file path taken, in UTF-16 units (Windows' own limit).
const MAX_PATH: u32 = 32_767;
/// Have the font scans read the font files DirectWrite lists.
pub fn install() {
vectorcraft_text::set_platform_font_files(font_files);
}
/// The local files of the fonts in DirectWrite's system font collection as it is now (fonts
/// loaded since the last call included). None before Windows 10, or when DirectWrite fails: the
/// font folders and the registry still list the installed fonts.
fn font_files() -> Vec<String> {
match collection_files() {
Ok(files) => files.into_iter().collect(),
Err(e) => {
log::debug!("DirectWrite's system fonts aren't listed: {e}");
Vec::new()
}
}
}
fn collection_files() -> windows::core::Result<BTreeSet<String>> {
// SAFETY: plain DirectWrite calls on interfaces it returned, each checked for failure; the
// collection is asked to check for fonts loaded since the factory last looked.
let set = unsafe {
let factory: IDWriteFactory3 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?;
let mut collection = None;
factory.GetSystemFontCollection(false, &mut collection, true)?;
let Some(collection) = collection else { return Ok(BTreeSet::new()) };
collection.GetFontSet()?
};
// SAFETY: a count of the set's fonts.
let count = unsafe { set.GetFontCount() }.min(MAX_FONTS);
// Faces of one file (a collection's, a variable font's instances) name it once.
Ok((0..count).filter_map(|i| local_path(&set, i)).collect())
}
/// The path of the file holding font `i` of `set`, when it is a local file (not a downloadable
/// font, nor one in memory).
fn local_path(set: &IDWriteFontSet, i: u32) -> Option<String> {
// SAFETY: `i` is below the set's count; the key DirectWrite hands back stays valid while
// `file` (which owns it) lives, and is passed back with its own size; the path buffer is
// sized from DirectWrite's answer plus the terminating NUL.
unsafe {
let file = set.GetFontFaceReference(i).ok()?.GetFontFile().ok()?;
let loader: IDWriteLocalFontFileLoader = file.GetLoader().ok()?.cast().ok()?;
let (mut key, mut size) = (std::ptr::null_mut(), 0u32);
file.GetReferenceKey(&mut key, &mut size).ok()?;
let len = loader.GetFilePathLengthFromKey(key, size).ok()?;
if len == 0 || len > MAX_PATH {
return None;
}
let mut path = vec![0u16; len as usize + 1];
loader.GetFilePathFromKey(key, size, &mut path).ok()?;
Some(String::from_utf16_lossy(path.get(..len as usize)?))
}
}
#[cfg(test)]
mod tests {
/// DirectWrite lists the installed fonts, by their files' full paths.
#[test]
fn directwrite_lists_the_installed_font_files() {
let files = super::font_files();
assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first());
let fonts = std::path::Path::new(&std::env::var("WINDIR").unwrap()).join("Fonts").to_string_lossy().to_lowercase();
assert!(files.iter().any(|f| f.to_lowercase().starts_with(&fonts)), "{} files, {:?}", files.len(), files.first());
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
//! never keeps the app from starting.
//!
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// What winit 0.30 `dlopen`s for an X11 window: x11-dl opens Xlib, Xlib-xcb, Xcursor and XInput2,
/// x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11.
const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
/// and an X display to open.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
}
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
/// count as missing.
pub fn x11_libraries_found() -> bool {
let cache = read_ldconfig();
let dirs = search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref());
X11_LIBS
.iter()
.all(|lib| cache.as_deref().is_some_and(|out| ldconfig_has(out, lib, ldconfig_arch_tag())) || dirs.iter().any(|d| d.join(lib).exists()))
}
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
fn ldconfig_arch_tag() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86-64")
} else if cfg!(target_arch = "aarch64") {
Some("AArch64")
} else {
None
}
}
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
output.lines().any(|line| {
let Some((name, rest)) = line.trim_start().split_once(' ') else { return false };
if name != soname {
return false;
}
match arch_tag {
None => true,
Some(tag) => {
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
flags.split(',').any(|f| f.trim() == tag)
}
}
})
}
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian). A
/// real listing starts with "N libs found in cache".
fn read_ldconfig() -> Option<String> {
["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"].into_iter().find_map(|cmd| {
let out = std::process::Command::new(cmd).arg("-p").output().ok().filter(|out| out.status.success())?;
let text = String::from_utf8_lossy(&out.stdout).into_owned();
text.lines().next().is_some_and(|l| l.contains("libs found in cache")).then_some(text)
})
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") {
"aarch64-linux-gnu"
} else if cfg!(target_arch = "riscv64") {
"riscv64-linux-gnu"
} else {
"x86_64-linux-gnu"
};
dirs.extend(["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"].map(PathBuf::from));
dirs.push(Path::new("/usr/lib").join(triplet));
dirs.push(Path::new("/lib").join(triplet));
dirs
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
}
#[test]
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
assert!(!wanted(Some("automatic"), &wayland));
assert!(!wanted(None, &wayland));
assert!(!wanted(Some("wayland"), &wayland));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
}
#[test]
fn ldconfig_parsing_matches_name_and_architecture() {
let cache = "12 libs found in cache `/etc/ld.so.cache'\n\
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n";
assert!(ldconfig_has(cache, "libX11.so.6", Some("x86-64")));
assert!(!ldconfig_has(cache, "libX11.so.6", Some("AArch64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(cache, "libxkbcommon-x11.so.0", Some("x86-64")));
assert!(ldconfig_has(cache, "libxkbcommon-x11.so.0", None));
assert!(!ldconfig_has(cache, "libX11.so", None));
}
}
+96
View File
@@ -0,0 +1,96 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Invalid roots must exit before desktop initialization on every platform, including a CI
/// machine with no display. A regression must fail rather than hang with a window open.
fn rejects(args: &[&str], message: &str) {
rejects_with_env(args, &[], message);
}
fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
let mut child = Command::new(BIN)
.args(args)
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
.env("VECTORCRAFT_NO_PREFS", "1")
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
}
if Instant::now() >= deadline {
child.kill().unwrap();
break true;
}
std::thread::sleep(Duration::from_millis(10));
};
let out = child.wait_with_output().unwrap();
assert!(!timed_out, "{args:?}: desktop started or did not exit promptly");
assert!(!out.status.success(), "{args:?}");
assert!(out.stdout.is_empty(), "{args:?}: {:?}", out.stdout);
// Exact stderr excludes unrelated startup warnings or display errors that could otherwise
// make a malformed invocation fail for an unrelated reason.
assert_eq!(String::from_utf8(out.stderr).unwrap(), format!("vectorcraft: {message}\n"), "{args:?}");
}
#[test]
fn missing_empty_or_option_operands_fail_before_startup() {
for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&["--control", "7979", flag], &format!("{flag} needs a folder"));
rejects(&[flag], &format!("{flag} needs a folder"));
rejects(&["--control", "7979", &format!("{flag}=")], &format!("{flag} needs a non-empty folder"));
rejects(&[flag, ""], &format!("{flag} needs a non-empty folder"));
for next in ["--version", "--control", "--automation-write-root", "-folder"] {
rejects(&[flag, next], &format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
}
}
#[test]
fn duplicate_roots_fail_for_both_spellings() {
for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
}
}
#[test]
fn explicit_dash_folder_does_not_consume_version() {
let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
}
/// `--control` without a port no longer starts the app without a control server, and the
/// argument after it isn't reinterpreted (#1373).
#[test]
fn control_without_a_valid_port_fails_before_startup() {
let need = "--control needs a port number from 1 to 65535";
rejects(&["--control", "--automation-read-root", ".", "--automation-write-root", "."], &format!("{need}, not `--automation-read-root`"));
rejects(&["--control"], need);
rejects(&["--control", "abc"], &format!("{need}, not `abc`"));
rejects(&["--control=0"], &format!("{need}, not `0`"));
rejects(&["--control="], &format!("{need}, not ``"));
rejects(&["--control", "7979", "--control", "7980"], "--control given twice");
rejects_with_env(&[], &[("VECTORCRAFT_CONTROL_PORT", "abc")], "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535, not `abc`");
}
#[test]
fn version_wins_over_a_bad_control_port_variable() {
let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 3V21M3 12H21" stroke-width="1.5" /><rect x="6.5" y="6.5" width="11" height="11" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 336 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 173 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor" stroke-width="1"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="none"/></svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 265 B

+507 -55
View File
@@ -2,49 +2,49 @@
"schema": 1,
"repo": "storytold/vectorcraft",
"branch": "main",
"head": "fe9f3afcb7d32032e8f332b0594692a03e275b84",
"head_date": "2026-10-08T00:36:16Z",
"commits": 607,
"head": "866ea5878a7f6db7dd3d6b1287f9a9bb3a241c33",
"head_date": "2026-10-10T00:04:42Z",
"commits": 906,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 15,
"commits": 119,
"lines_added": 172276,
"lines_deleted": 40329,
"prs": 18,
"commits": 124,
"lines_added": 173772,
"lines_deleted": 40385,
"binary_added": 37,
"binary_deleted": 2,
"first_commit": "2026-09-30T09:12:46Z",
"last_commit": "2026-10-07T22:04:30Z"
"last_commit": "2026-10-08T14:01:10Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 176,
"commits": 394,
"lines_added": 212929,
"lines_deleted": 24160,
"prs": 328,
"commits": 561,
"lines_added": 253056,
"lines_deleted": 31036,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-02T15:22:29Z",
"last_commit": "2026-10-08T00:36:16Z"
"last_commit": "2026-10-09T23:27:19Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 7,
"lines_added": 456,
"lines_deleted": 49,
"prs": 8,
"commits": 9,
"lines_added": 812,
"lines_deleted": 77,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T06:45:57Z",
"last_commit": "2026-10-04T08:14:20Z"
"last_commit": "2026-10-09T04:35:07Z"
},
{
"login": "theironchef",
@@ -102,14 +102,14 @@
"login": "imachus",
"people_entry": false,
"github_name": null,
"prs": 12,
"commits": 12,
"lines_added": 6302,
"lines_deleted": 212,
"prs": 21,
"commits": 22,
"lines_added": 7242,
"lines_deleted": 320,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T10:06:18Z",
"last_commit": "2026-10-07T22:20:10Z"
"last_commit": "2026-10-09T08:36:30Z"
},
{
"login": "petrleocompel",
@@ -128,14 +128,14 @@
"login": "fahrim",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 6,
"lines_added": 1964,
"lines_deleted": 76,
"prs": 4,
"commits": 7,
"lines_added": 2134,
"lines_deleted": 83,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T12:48:28Z",
"last_commit": "2026-10-07T23:36:17Z"
"last_commit": "2026-10-08T01:33:25Z"
},
{
"login": "EthanSuquet",
@@ -258,14 +258,14 @@
"login": "Haven-King",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 467,
"lines_deleted": 76,
"prs": 9,
"commits": 19,
"lines_added": 8627,
"lines_deleted": 720,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T18:10:39Z",
"last_commit": "2026-10-07T18:40:49Z"
"last_commit": "2026-10-09T03:25:56Z"
},
{
"login": "njmueller",
@@ -297,14 +297,430 @@
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 76,
"lines_deleted": 21,
"prs": 3,
"commits": 3,
"lines_added": 392,
"lines_deleted": 44,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T23:09:37Z",
"last_commit": "2026-10-07T23:09:37Z"
"last_commit": "2026-10-09T17:33:23Z"
},
{
"login": "raytheengineer",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 94,
"lines_deleted": 12,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T03:19:20Z",
"last_commit": "2026-10-08T03:19:20Z"
},
{
"login": "bobtherobot",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 6,
"lines_added": 824,
"lines_deleted": 93,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T03:25:09Z",
"last_commit": "2026-10-08T16:57:16Z"
},
{
"login": "devCangrejo",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 859,
"lines_deleted": 13,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T06:30:51Z",
"last_commit": "2026-10-08T06:30:51Z"
},
{
"login": "wstock",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 7,
"lines_added": 749,
"lines_deleted": 92,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T07:45:45Z",
"last_commit": "2026-10-08T11:46:16Z"
},
{
"login": "switch87",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 3,
"lines_added": 638,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T09:25:54Z",
"last_commit": "2026-10-08T09:42:11Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 52,
"lines_deleted": 52,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:01Z",
"last_commit": "2026-10-08T14:01:57Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 74,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:58:51Z",
"last_commit": "2026-10-08T12:58:51Z"
},
{
"login": "omellado-gh",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 1,
"lines_added": 335,
"lines_deleted": 7,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:05:09Z",
"last_commit": "2026-10-08T13:05:09Z"
},
{
"login": "callmeBalloch",
"people_entry": true,
"github_name": null,
"prs": 5,
"commits": 13,
"lines_added": 11741,
"lines_deleted": 698,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T14:01:08Z",
"last_commit": "2026-10-09T13:45:55Z"
},
{
"login": "jeremyfry",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 57,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T14:57:01Z",
"last_commit": "2026-10-08T14:57:01Z"
},
{
"login": "dbambus",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 72,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T15:38:18Z",
"last_commit": "2026-10-08T15:38:18Z"
},
{
"login": "mo0kid",
"people_entry": true,
"github_name": "David John Whiting",
"prs": 2,
"commits": 6,
"lines_added": 1973,
"lines_deleted": 144,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T19:50:54Z",
"last_commit": "2026-10-08T22:03:34Z"
},
{
"login": "MaksPV",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 3682,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T20:44:45Z",
"last_commit": "2026-10-08T20:44:45Z"
},
{
"login": "omd0",
"people_entry": true,
"github_name": "Emad Fussi",
"prs": 1,
"commits": 1,
"lines_added": 291,
"lines_deleted": 25,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T21:34:00Z",
"last_commit": "2026-10-08T21:34:00Z"
},
{
"login": "leblanc-simon",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 4,
"lines_added": 3721,
"lines_deleted": 60,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T22:00:03Z",
"last_commit": "2026-10-08T23:14:11Z"
},
{
"login": "dotCooCoo",
"people_entry": true,
"github_name": ".CooCoo",
"prs": 5,
"commits": 6,
"lines_added": 1701,
"lines_deleted": 139,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T22:18:57Z",
"last_commit": "2026-10-09T15:33:59Z"
},
{
"login": "u9151131269-a11y",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 1,
"lines_added": 37,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T23:05:11Z",
"last_commit": "2026-10-08T23:05:11Z"
},
{
"login": "HEOJUNFO",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 112,
"lines_deleted": 17,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T23:49:03Z",
"last_commit": "2026-10-08T23:49:03Z"
},
{
"login": "DisasterAreaDesigns",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 14,
"lines_added": 3359,
"lines_deleted": 116,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T00:15:28Z",
"last_commit": "2026-10-09T14:43:23Z"
},
{
"login": "jax-spenser",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 3098,
"lines_deleted": 39,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T01:41:46Z",
"last_commit": "2026-10-09T01:41:46Z"
},
{
"login": "mateususa",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 18,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T02:19:59Z",
"last_commit": "2026-10-09T02:19:59Z"
},
{
"login": "fastmikey99",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 490,
"lines_deleted": 31,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T04:11:07Z",
"last_commit": "2026-10-09T04:11:08Z"
},
{
"login": "AutoHenry",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 351,
"lines_deleted": 27,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T10:50:26Z",
"last_commit": "2026-10-09T10:50:26Z"
},
{
"login": "jmverges",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 382,
"lines_deleted": 73,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T11:17:50Z",
"last_commit": "2026-10-09T19:03:09Z"
},
{
"login": "dmatviichuk",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 3893,
"lines_deleted": 109,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:15:12Z",
"last_commit": "2026-10-09T17:51:43Z"
},
{
"login": "hamada147",
"people_entry": true,
"github_name": "Ahmed Moussa",
"prs": 1,
"commits": 2,
"lines_added": 671,
"lines_deleted": 62,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T13:09:34Z",
"last_commit": "2026-10-09T14:18:54Z"
},
{
"login": "haranishi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 571,
"lines_deleted": 7,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T17:53:07Z",
"last_commit": "2026-10-09T17:53:07Z"
},
{
"login": "Messhias",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 3,
"lines_added": 268,
"lines_deleted": 27,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T19:00:03Z",
"last_commit": "2026-10-09T19:26:55Z"
},
{
"login": "rschuerer",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 450,
"lines_deleted": 35,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T23:08:33Z",
"last_commit": "2026-10-09T23:08:33Z"
},
{
"login": "dylanmtaylor",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 34,
"lines_deleted": 34,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:04:42Z",
"last_commit": "2026-10-10T00:04:42Z"
},
{
"login": "llduyll10",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "visuarte",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
}
],
"models": [
@@ -313,36 +729,72 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 567,
"lines_added": 382239,
"lines_deleted": 67174
"commits": 787,
"lines_added": 439476,
"lines_deleted": 75146
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 6,
"lines_added": 660,
"lines_deleted": 34
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 3,
"lines_added": 1672,
"lines_deleted": 42
"commits": 22,
"lines_added": 7688,
"lines_deleted": 196
},
{
"id": "anthropic/claude-fable/5.1",
"company": "Anthropic",
"model": "Claude Fable",
"version": "5.1",
"commits": 9,
"lines_added": 8008,
"lines_deleted": 2754
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 4,
"lines_added": 1729,
"lines_deleted": 44
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 3,
"lines_added": 448,
"lines_deleted": 120
},
{
"id": "anysphere/cursor",
"company": "Anysphere",
"model": "Cursor",
"version": "",
"commits": 2,
"lines_added": 63,
"lines_deleted": 25
},
{
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 1,
"lines_added": 6809,
"lines_deleted": 2627
"lines_added": 55,
"lines_deleted": 4
},
{
"id": "anthropic/claude-opus/4.7",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.7",
"commits": 1,
"lines_added": 94,
"lines_deleted": 12
}
]
}
+70 -2
View File
@@ -17,11 +17,79 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.lollinoo]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.callmeBalloch]
real_name = "Riccardo Balloch"
[people.crqch]
sync_github_name = true
[people.mo0kid]
sync_github_name = true
[people.omd0]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.AutoHenry]
real_name = "Hal Goff"
display_name = "Hal Goff, HG Studio"
sync_github_name = false
[people.hamada147]
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
[people.Messhias]
real_name = "Fabio William Conceição"
display_name = "Messhias"
sync_github_name = false
[people.KattKnight]
display_name = "KattKnight (ayla.dsc)"
sync_github_name = false
[people.NoJuanNobody]
real_name = "Alejandro Londono"
display_name = "alex"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.Losminek]
sync_github_name = true
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.sanyamsingh001]
display_name = "sanyamsingh001"
sync_github_name = false
[people.aguo890]
real_name = "Aaron Guo"
display_name = "Aaron Guo"
sync_github_name = false
[people.Nael-Nathanael]
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
sync_github_name = false
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "vectorcraft-affinity"
description = "Bounded reader for native Affinity documents: archive, object stream and document model"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[features]
# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
synth = []
[dependencies]
crc32fast = { workspace = true }
flate2 = { workspace = true }
image = { workspace = true }
ruzstd = "0.9"
[dev-dependencies]
proptest = { workspace = true }
+146
View File
@@ -0,0 +1,146 @@
# vectorcraft-affinity
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
and hands the engine a document in pixels with every transform composed. It does not write
Affinity files.
## What is imported
| Affinity | VectorCraft |
|---|---|
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| Vector masks (`AdCh` curves and shapes) | clip group |
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
active one is used), outlined or scaled text, text fields such as page numbers, frames with
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
with a warning that names the reason; that preview can't be placed, used as a template or mined
for swatches and styles. Save never writes back over the Affinity file.
## Provenance and clean-room
Affinity has no published file-format specification, so this reader is built only from a public
description of the format and from public files, never from Affinity itself. This is how the sibling app PhotoCraft already treats formats that
are only partly documented:
its PSD reader fills the gaps in Adobe's specification from the MIT-licensed psd-tools and
ag-psd, and its camera raw decoder recovered Nikon's compression tables by black-box analysis
of CC0 sample files.
* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or
disassembled for this work, and nothing from an Affinity installation (program code, resources,
presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample
is a document its author published.
* **A public, permissively licensed description.** The container and object-stream layout was
learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at
`04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described
in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such
as Inkscape's Affinity extension) was read.
* **Every structure checked against public files.** afread doesn't say how its author learned the
format, so nothing here rests on it alone: each structure the reader relies on is confirmed by
the public documents. Every archive entry carries a CRC-32 that must match, and every field in
the object stream names its own type, so a misread layout fails loudly instead of producing
plausible wrong data.
* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was
worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's
render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows,
macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens
all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
(no licence stated, or GPL or non-commercial terms) were only opened locally to compare
pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned
files, which are CC0, MIT or Apache-2.0 only.
* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test
files are fetched at pinned commits and sha256-verified, never committed.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself.
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
## Validation
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations.
* `cargo xtask corpus --affinity` fetches 51 public CC0/MIT/Apache-2.0 documents at pinned
commits, SHA-256 verified against `xtask/affinity-corpus.sha256`, with upstream license notices.
Of these, 33 are current `.af`: four CC0 vector illustrations and 28 original Affinity 3.2.3
feature probes plus one separately identified derived regression input from
[SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance
notices were inspected, never that project's importer or authoring scripts.
* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current
`.af` files. Native raster tests compare decoded pixels with independent embedded images;
text tests assert Unicode run boundaries, fallback fonts and feature retention.
* `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft`
save/reload, and SVG/PDF/PSD export of every board in all 33 current files. It checks SVG/PDF
dimensions and reimport, and independently decodes PSD merged pixels using the published
specification. These are VectorCraft consistency checks, not independent Affinity PSD oracles.
Saved-thumbnail comparisons cover the existing 22 documents plus nine new representative
fixtures, checking every board; unsupported effects and text layout are not claimed visually
faithful. The Affinity corpus workflow requires the files, so missing fixtures cannot silently
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
multiple spreads and resolution conversion, alongside text recovery and native save/export.
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
reader (`container`) and the object stream (`stream`):
```sh
cd crates/affinity
cargo +nightly fuzz run stream -- -max_total_time=600
```
The source audit, licensing decisions and remaining complex-document/font cases are recorded in
[affinity-validation.md](../../docs/affinity-validation.md). Reopening VectorCraft's exports in
Affinity remains an independent validation step that hasn't been done.
What has not been verified: files from Affinity builds or platforms outside the public set, files
written by other applications, rotated or skewed images against Affinity's render, mask polarity
against a render with and without the mask, and anything listed as a warning above. The `synth`
builders write only what this reader accepts; Affinity has never opened their output, so they are
test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
text, preserving its characters.
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
XMP metadata. This reader never imports those fields.
+5
View File
@@ -0,0 +1,5 @@
/target
/corpus
/artifacts
/coverage
/Cargo.lock
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "vectorcraft-affinity-fuzz"
version = "0.0.0"
edition = "2024"
publish = false
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
vectorcraft-affinity = { path = "..", features = ["synth"] }
[[bin]]
name = "container"
path = "fuzz_targets/container.rs"
test = false
doc = false
bench = false
[[bin]]
name = "stream"
path = "fuzz_targets/stream.rs"
test = false
doc = false
bench = false
[workspace]
members = ["."]
@@ -0,0 +1,10 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
// The whole native reader (archive, object stream, model) and the preview, on arbitrary bytes.
fuzz_target!(|bytes: &[u8]| {
let _ = vectorcraft_affinity::container::header(bytes);
let _ = vectorcraft_affinity::preview(bytes);
let _ = vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});
@@ -0,0 +1,11 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
// The doc.dat object stream and the model built from it, wrapped in a stored archive entry so
// mutations reach the tagged grammar instead of the archive's checksum.
fuzz_target!(|stream: &[u8]| {
let _ = vectorcraft_affinity::stream::parse(stream);
let file = vectorcraft_affinity::synth::container(&[("doc.dat", stream, vectorcraft_affinity::synth::Method::Stored)], None);
let _ = vectorcraft_affinity::read(&file, vectorcraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});
+105
View File
@@ -0,0 +1,105 @@
//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
//! another type, so the document mapping treats odd data as absent rather than failing.
use crate::stream::{ObjId, Object, Stream, Tag, Value};
impl Stream {
pub fn object(&self, id: ObjId) -> Option<&Object> {
self.objects.get(id)
}
pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
self.object(id)?.get(Tag::of(tag))
}
pub fn class(&self, id: ObjId) -> Option<Tag> {
self.object(id).map(|o| o.class)
}
pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
self.class(id) == Some(Tag::of(class))
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
Value::Obj(o) => *o,
_ => None,
}
}
/// An array of objects (nulls skipped).
pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec<ObjId> {
match self.field(id, tag) {
Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
_ => Vec::new(),
}
}
pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match self.field(id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option<i64> {
match self.field(id, tag)? {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option<bool> {
match self.field(id, tag)? {
Value::Bool(b) => Some(*b),
_ => None,
}
}
pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Str(s) => Some(s),
_ => None,
}
}
pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
match self.field(id, tag)? {
Value::Enum { id, version } => Some((*id, *version)),
_ => None,
}
}
/// A fixed vector of `N` finite floats (points, rectangles, transforms).
pub fn floats<const N: usize>(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
match self.field(id, tag)? {
Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
let mut a = [0.0; N];
a.copy_from_slice(v);
Some(a)
}
_ => None,
}
}
pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
match self.field(id, tag)? {
Value::Bytes(b) | Value::Blob(b) => Some(b),
_ => None,
}
}
pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Entry(s) => Some(s),
_ => None,
}
}
/// Untagged fields of a positional object, in order.
pub fn positional(&self, id: ObjId) -> impl Iterator<Item = &Value> {
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
}
}
+371
View File
@@ -0,0 +1,371 @@
//! The Affinity archive: header, the chain of file allocation tables (one per save) and
//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
use std::collections::{HashMap, HashSet};
use std::io::Read;
use crate::Error;
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
pub const VERSIONS: std::ops::RangeInclusive<u16> = 8..=12;
/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
/// Decompression budget for one import. Every entry declares its size, which is checked against
/// these limits before anything is allocated, and the decoded length must match it exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Largest single entry (`doc.dat` or one tile).
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
}
}
/// The container header, all integers little-endian.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub version: u16,
/// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
pub flags: u16,
pub(crate) fat_offset: u64,
pub(crate) thumbnail_offset: u64,
/// Save time in Unix seconds, as written by the application.
pub saved: u32,
}
/// One FAT record, as of the newest table that mentions its id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub id: u32,
pub name: String,
offset: u64,
size: u64,
compressed: u64,
crc: u32,
compression: u8,
}
impl Entry {
/// Decompressed size declared by the table.
pub fn size(&self) -> u64 {
self.size
}
}
#[derive(Debug)]
pub struct Archive<'a> {
bytes: &'a [u8],
pub header: Header,
/// Head revision of every named, non-deleted entry.
entries: HashMap<String, Entry>,
/// Number of tables (saves) in the chain.
pub revisions: usize,
extracted: usize,
limits: Limits,
}
pub(crate) struct Cursor<'a> {
bytes: &'a [u8],
pub(crate) pos: usize,
}
impl<'a> Cursor<'a> {
pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
Self { bytes, pos }
}
pub(crate) fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
self.pos = end;
Ok(s)
}
pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
let s = self.take(N)?;
let mut a = [0u8; N];
a.copy_from_slice(s);
Ok(a)
}
pub(crate) fn u8(&mut self) -> Result<u8, Error> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.array()?))
}
}
fn offset(v: u64) -> Result<usize, Error> {
usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
}
/// True when `bytes` start with the Affinity container signature (documents and libraries).
pub fn is_affinity(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
/// Check the signature, version and document kind.
pub fn header(bytes: &[u8]) -> Result<Header, Error> {
if !is_affinity(bytes) {
return Err(Error::Malformed("missing container signature"));
}
let mut c = Cursor::new(bytes, 4);
let version = c.u16()?;
let flags = c.u16()?;
if !VERSIONS.contains(&version) {
return Err(Error::Unsupported("unknown container version"));
}
if flags & 3 != 0 {
return Err(Error::Unsupported("container variant"));
}
if c.take(4)? != DOCUMENT_KIND {
return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
}
if c.take(4)? != b"#Inf" {
return Err(Error::Malformed("missing information header"));
}
let fat_offset = c.u64()?;
let thumbnail_offset = c.u64()?;
c.take(16)?;
let saved = u32::try_from(c.u64()?).unwrap_or(0);
c.take(8)?;
if c.take(4)? != b"Prot" {
return Err(Error::Malformed("missing protocol header"));
}
Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
}
impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
while next != 0 {
if !seen.insert(next) {
return Err(Error::Malformed("allocation tables form a cycle"));
}
tables += 1;
if tables > MAX_TABLES {
return Err(Error::Limit("too many saved revisions"));
}
let mut c = Cursor::new(bytes, offset(next)?);
let tag = c.array::<4>()?;
let level = match &tag {
b"#FAT" => 1,
b"#FT2" => 2,
b"#FT3" => 3,
b"#FT4" => 4,
_ => return Err(Error::Malformed("missing allocation table")),
};
next = c.u64()?;
let date = c.u64()?;
c.take(24)?;
let files = c.u32()?;
c.take(8)?;
let dirs = c.u16()?;
c.take(1)?;
for _ in 0..files {
let id = c.u32()?;
let flag = c.u8()?;
let mut record = None;
match flag {
0 | 1 => {
let offset = c.u64()?;
let size = c.u64()?;
let compressed = c.u64()?;
let crc = c.u32()?;
let stored = c.u8()?;
if level >= 2 {
c.take(4)?;
}
if level >= 4 {
c.take(4)?;
}
// Old tables number the methods; newer ones store the method byte itself.
let compression = if level <= 2 {
match stored {
1 => 0x01,
2 => 0x41,
3 => 0x81,
4 => 0xC1,
_ => 0,
}
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
}
if flag == 0 {
let len = usize::from(c.u16()?);
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
names.entry(id).or_insert(name);
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
latest.insert(id, (date, tables, record));
}
}
for _ in 0..dirs {
let len = usize::from(c.u16()?);
c.take(10)?;
c.take(len)?;
}
}
let mut entries = HashMap::new();
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
e.name = name.clone();
entries.insert(e.name.clone(), e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
}
pub fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.get(name)
}
pub fn entry_names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
/// Bytes extracted so far, counted against [`Limits::max_total`].
pub fn extracted(&self) -> usize {
self.extracted
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
if size > self.limits.max_entry {
return Err(Error::Limit("archive entry too large"));
}
let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
}
let method = e.compression & 3;
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
let data = c.take(offset(stored_len)?)?;
let mut out = match method {
1 => inflate(data, size)?,
2 => unzstd(data, size)?,
_ => data.to_vec(),
};
if out.len() != size {
return Err(Error::Malformed("archive entry size mismatch"));
}
unpredict(&mut out, e.compression)?;
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
self.extracted = total;
Ok(out)
}
}
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
/// without unbounded allocation.
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
let mut out = Vec::with_capacity(size.min(64 << 20));
let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
Ok(out)
}
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
let mut src = data;
let mut out = Vec::new();
// Entries may hold several concatenated frames.
while !src.is_empty() {
let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
.map_err(|_| Error::Malformed("corrupt compressed entry"))?;
let frame = read_bounded(decoder, left)?;
if frame.is_empty() {
return Err(Error::Malformed("corrupt compressed entry"));
}
out.extend_from_slice(&frame);
if out.len() > size {
return Err(Error::Malformed("archive entry size mismatch"));
}
}
Ok(out)
}
/// Undo the byte predictor selected by the high bits of the compression byte.
fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
match (compression & 0xC0, compression & 0x20 != 0) {
(0x00, _) => {}
(0x40, _) => byte_delta(b),
(0x80, false) => {
let mut prev = 0u16;
for pair in b.as_chunks_mut::<2>().0.iter_mut() {
let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev);
pair.copy_from_slice(&v.to_le_bytes());
prev = v;
}
}
(0x80, true) => {
byte_delta(b);
// A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes.
if b.len() == 0x10000 {
let planes = b.to_vec();
let (hi, lo) = planes.split_at(0x8000);
for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else {
return Err(Error::Malformed("tile size"));
};
out.copy_from_slice(&[l[0], h[0], l[1], h[1]]);
}
}
}
_ => return Err(Error::Unsupported("archive entry predictor")),
}
Ok(())
}
fn byte_delta(b: &mut [u8]) {
let mut prev = 0u8;
for v in b {
*v = v.wrapping_add(prev);
prev = *v;
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
//! node space.
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
/// A shared-object stream is bounded, but a compound graph can expand exponentially. Count
/// each visit and each curve record / generated contour / transformed segment across the whole
/// outline, including repeated references. Match the document reader's nesting limit.
const MAX_OUTLINE_DEPTH: usize = 128;
const MAX_OUTLINE_VISITS: usize = 100_000;
struct OutlineBudget {
active: Vec<ObjId>,
visits: usize,
work: usize,
failed: bool,
}
impl OutlineBudget {
fn fail(&mut self, r: &mut Reader, warning: &'static str) -> Option<()> {
if !self.failed {
r.warn(warning);
}
self.failed = true;
None
}
fn spend(&mut self, r: &mut Reader, work: usize) -> Option<()> {
if work > self.work {
return self.fail(r, "compound outlines exceeding the geometry work limit");
}
self.work -= work;
Some(())
}
fn path(&mut self, r: &mut Reader, path: &Path) -> Option<()> {
for subpath in &path.subpaths {
self.spend(r, 1)?;
self.spend(r, subpath.segments.len())?;
}
Some(())
}
}
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let mut budget = OutlineBudget { active: Vec::new(), visits: MAX_OUTLINE_VISITS, work: MAX_NODES, failed: false };
bounded_outline(r, id, class, world, &mut budget)
}
fn bounded_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
if budget.active.contains(&id) {
budget.fail(r, "a compound outline that contains itself")?;
}
if budget.active.len() >= MAX_OUTLINE_DEPTH {
budget.fail(r, "compound outlines nested deeper than 128 levels")?;
}
if budget.visits == 0 {
budget.fail(r, "compound outlines exceeding the operand visit limit")?;
}
budget.visits -= 1;
budget.active.push(id);
let result = outline_inner(r, id, class, world, budget);
// The popped value is our own stack marker, and is no longer needed.
let _ = budget.active.pop();
result
}
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
let path = curves(r, crvs, budget)?;
budget.path(r, &path)?;
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
let child_world = local.then(world);
if !child_world.is_finite() {
budget.fail(r, "a compound operand with an invalid transform")?;
}
if let Some((mut p, _)) = bounded_outline(r, c, class, child_world, budget) {
// Charge before transforming or extending. Transform the owned path in place
// so nested compounds do not make an additional copy of every contour.
budget.path(r, &p)?;
for subpath in &mut p.subpaths {
subpath.start = local.apply(subpath.start);
for segment in &mut subpath.segments {
*segment = segment.map(|point| local.apply(point));
}
}
path.subpaths.extend(p.subpaths);
} else if budget.failed {
// Never present a partial compound as complete after a cycle or budget limit.
return None;
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
None => {
let master = s.obj(id, b"ShpM")?;
(s.obj(master, b"Shpe")?, master)
}
};
let b = s.floats::<4>(box_owner, b"ShpB")?;
let path = crate::shapes::shape(r, shape, b, world)?;
budget.path(r, &path)?;
Some((path, false))
}
}
}
/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Path> {
let s = r.s;
let data = s.obj(crvs, b"Data")?;
let mut fields = s.positional(data);
fields.next()?;
let count = match fields.next()? {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
budget.spend(r, count)?;
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
let closed = match fields.next()? {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
if seen > MAX_NODES {
budget.fail(r, "curves with more than four million nodes")?;
}
budget.spend(r, records.len())?;
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
}
}
if corners.is_some() && count > 1 {
r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
}
Some(path)
}
struct Anchor {
p: Point,
inh: Option<Point>,
out: Option<Point>,
/// Index of the anchor's record (live corners refer to records).
record: usize,
}
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
}
1 => {
// A leading out-handle without an anchor is edit state only.
if let Some(a) = out.last_mut() {
a.out = Some(p);
}
}
2 => pending_in = Some(p),
_ => return None,
}
}
Some(out)
}
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
// A closed subpath repeats its first anchor; its radius may be on either copy.
if closed
&& pts.len() > 1
&& pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
&& let Some((_, r)) = pts.pop()
&& let Some(f) = pts.first_mut()
{
f.1 = f.1.max(r);
}
return Some(crate::shapes::fillet(&pts, closed));
}
}
let segments = anchors
.windows(2)
.filter_map(|w| {
let [a, b] = w else { return None };
Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
})
.collect();
Some(SubPath { start: first.p, segments, closed })
}
/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
Some(
idx.iter()
.zip(rad)
.filter_map(|(i, r)| match (i, r) {
(Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
_ => None,
})
.collect(),
)
}
+51
View File
@@ -0,0 +1,51 @@
//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from
//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the
//! subset of the document model VectorCraft imports.
//!
//! Layouts follow the MIT-licensed afread reference and were checked against public documents
//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance
//! and the exact supported subset. Nothing here writes Affinity files.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::fmt;
mod access;
pub mod container;
mod geometry;
pub mod model;
pub mod paint;
pub mod preview;
mod raster;
mod shapes;
pub mod stream;
#[cfg(any(test, feature = "synth"))]
pub mod synth;
mod text;
pub use container::{Archive, Limits, MAGIC, is_affinity};
pub use model::{Document, read};
pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview};
/// Why a file could not be read. Messages are short reasons; callers add advice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
Malformed(&'static str),
Unsupported(&'static str),
Limit(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"),
Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"),
Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"),
}
}
}
impl std::error::Error for Error {}
#[cfg(test)]
mod tests;
+618
View File
@@ -0,0 +1,618 @@
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
//! text and images, with transforms composed into document space. Anything else is reported
//! by [`Document::warnings`] instead of being dropped silently.
use std::collections::{BTreeMap, HashSet};
use crate::paint::{self, Paint, Stroke};
use crate::stream::{ObjId, Stream, Tag};
use crate::{Archive, Error, Limits, stream};
/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine(pub [f64; 6]);
impl Affine {
pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
/// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f).
pub(crate) fn from_xfrm(m: [f64; 6]) -> Self {
Self([m[0], m[3], m[1], m[4], m[2], m[5]])
}
pub fn then(self, outer: Self) -> Self {
let [a, b, c, d, e, f] = self.0;
let [oa, ob, oc, od, oe, of] = outer.0;
Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of])
}
pub fn apply(&self, p: Point) -> Point {
let [a, b, c, d, e, f] = self.0;
Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }
}
/// Geometric mean of the axis scales: how much a stroke width or font size grows.
pub fn scale(&self) -> f64 {
let [a, b, c, d, ..] = self.0;
(a * d - b * c).abs().sqrt()
}
pub fn is_finite(&self) -> bool {
self.0.iter().all(|v| v.is_finite())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
/// One cubic Bézier subpath, in document pixels.
#[derive(Debug, Clone, PartialEq)]
pub struct SubPath {
pub start: Point,
/// (control 1, control 2, end) per segment.
pub segments: Vec<[Point; 3]>,
pub closed: bool,
}
/// An outline in document pixels.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Path {
pub subpaths: Vec<SubPath>,
}
impl Path {
pub fn transformed(&self, m: Affine) -> Self {
Self {
subpaths: self
.subpaths
.iter()
.map(|s| SubPath {
start: m.apply(s.start),
segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(),
closed: s.closed,
})
.collect(),
}
}
}
/// Affinity's blend modes, numbered by the `Blnd` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Blend {
#[default]
Normal,
PassThrough,
Darken,
DarkerColor,
Multiply,
ColorBurn,
Lighten,
LighterColor,
Screen,
ColorDodge,
Add,
Overlay,
SoftLight,
HardLight,
VividLight,
PinLight,
LinearLight,
HardMix,
Difference,
Exclusion,
Subtract,
Hue,
Saturation,
Luminosity,
Color,
Average,
Negation,
Reflect,
Glow,
Erase,
}
impl Blend {
/// The enum (id, version) pairs observed in a document whose layers are named after their mode.
fn from_enum(id: u16, version: u16) -> Option<Self> {
use Blend::*;
Some(match (id, version) {
(0, _) => Normal,
(1, 0) => Darken,
(2, 1) => DarkerColor,
(2, 0) => Multiply,
(3, 0) => ColorBurn,
(4, 0) => Lighten,
(6, 1) => LighterColor,
(5, 0) => Screen,
(6, 0) => ColorDodge,
(7, 0) => Add,
(8, 0) => Overlay,
(9, 0) => SoftLight,
(10, 0) => HardLight,
(11, 0) => VividLight,
(12, 0) => PinLight,
(15, 1) => LinearLight,
(13, 0) => HardMix,
(14, 0) => Difference,
(15, 0) => Exclusion,
(16, 0) => Subtract,
(17, 0) => Hue,
(18, 0) => Saturation,
(19, 0) => Luminosity,
(20, 0) => Color,
(21, 0) => Average,
(22, 0) => Negation,
(23, 0) => Reflect,
(24, 0) => Glow,
(25, 0) => Erase,
_ => return None,
})
}
}
/// A run of text with one set of character attributes.
#[derive(Debug, Clone, PartialEq)]
pub struct TextRun {
pub text: String,
/// PostScript name and family of the chosen font.
pub postscript: String,
pub family: String,
/// CSS weight (400 regular, 700 bold).
pub weight: i64,
pub italic: bool,
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
pub features: Vec<String>,
pub all_caps: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
pub tracking: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option<f64>,
pub fill: Paint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
pub runs: Vec<TextRun>,
pub align: Align,
/// Artistic text: the first baseline's anchor point in node space (start, centre or end of the
/// line according to `align`). Frame text: the frame box in node space.
pub anchor: Point,
pub frame: Option<Rect>,
/// Node space to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Pixels,
/// Pixel space (0..w, 0..h) to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Kind {
/// Layer container (`Scop`).
Layer,
Group,
/// Artboard: an export rectangle and vector outline that clips its children.
/// `background` paints behind them, and `strokes` over them.
Artboard {
rect: Rect,
/// Actual clipping outline in document pixels. The artboard's export rectangle is its
/// bounding box, but a rotated or non-rectangular board still clips to this outline.
path: Path,
even_odd: bool,
background: Vec<Paint>,
strokes: Vec<Stroke>,
},
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
Shape {
path: Path,
fills: Vec<Paint>,
strokes: Vec<Stroke>,
even_odd: bool,
},
Text(Text),
Image(Image),
/// Something this reader does not import; its children are still imported.
Unsupported,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub class: Tag,
pub name: String,
pub visible: bool,
pub locked: bool,
pub opacity: f64,
pub blend: Blend,
pub kind: Kind,
/// Vector mask in document pixels: the node shows only inside it (nonzero fill).
pub mask: Option<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>,
/// Bottom to top.
pub children: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Spread {
/// Spread bounds in document pixels (the canvas, or the union of the artboards).
pub bounds: Rect,
/// Pages of a Publisher-style spread (one, or two facing pages), in document pixels.
pub pages: Vec<Rect>,
/// Transparent background; otherwise the page is white.
pub transparent: bool,
/// Bottom to top.
pub nodes: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec<Spread>,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option<String>,
/// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec<String>,
}
/// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
*self.warnings.entry(what.into()).or_default() += 1;
}
fn document(mut self) -> Result<Document, Error> {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
let saved_by = s.obj(root, b"NVer").map(|v| {
let product = s.str(v, b"Prod").unwrap_or("Affinity");
match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) {
(Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"),
_ => product.to_string(),
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
let mut spreads = Vec::new();
for spread in s.objs(doc, b"Chld") {
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
continue;
}
let pages = pages(s, spread);
let default_size =
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
spreads.push(Spread { bounds, pages, transparent, nodes });
}
if spreads.is_empty() {
return Err(Error::Malformed("document has no pages"));
}
if !s.objs(doc, b"MpCh").is_empty() {
self.warn("master pages (their content is not placed on the pages that use them)");
}
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
}
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
if s.bool(c, b"Visi") == Some(false) {
continue;
}
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
match crate::raster::mask(self, c, local.then(world))? {
Some(m) if mask.is_none() => mask = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
continue;
}
if let Some(n) = self.node(c, world, depth + 1)? {
out.push(n);
}
}
Ok((out, mask))
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
self.nodes += 1;
if self.nodes > MAX_NODES {
return Err(Error::Limit("too many layers"));
}
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
return Ok(None);
}
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
let blend = match s.enumeration(id, b"Blnd") {
None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough,
None => Blend::Normal,
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
self.warn("an unknown blend mode (imported as Normal)");
Blend::Normal
}),
};
let kind = if !world.is_finite() {
self.warn("a layer with an invalid transform");
Kind::Unsupported
} else {
self.kind(id, class, world, parent)?
};
// A compound's children are its operands, already merged into its outline.
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
let (mask, attached_mask) = self.attached_masks(id, world)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
let s = self.s;
Ok(match &class.0.to_be_bytes() {
b"Scop" => Kind::Layer,
b"Grup" => Kind::Group,
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
// Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp`
// property object in `phrp`, while older documents use `ABEn`. Ordinary shapes
// in that fixture have neither. See the pinned corpus and validation audit.
if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) {
return Ok(self.artboard(id, world));
}
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
self.warn(format!("a {} that could not be read", describe(class)));
return Ok(Kind::Unsupported);
};
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t),
None => {
self.warn("text whose story could not be read (master-page text, or an unknown layout)");
Kind::Unsupported
}
},
b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? {
Some(img) => Kind::Image(img),
None => Kind::Unsupported,
},
b"EmbN" => match crate::raster::embedded(self, id, world)? {
Some(img) => {
self.warn("embedded documents and symbols (imported as pictures of them)");
Kind::Image(img)
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
},
b"FRst" => {
// A fill layer paints its whole bitmap area; its gradient lives in page space.
let b = s.obj(id, b"Bitm");
let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0);
match (size(b"BmpW"), size(b"BmpH")) {
(Some(w), Some(h)) => {
let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] };
let fills = paint::fills(self, id, parent, b"BFFl");
Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false }
}
_ => {
self.warn("fill layers without an area");
Kind::Unsupported
}
}
}
_ => {
self.warn(format!("{} layers", describe(class)));
Kind::Unsupported
}
})
}
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = None;
for a in attached {
let class = s.class(a).unwrap_or(Tag(0));
if s.bool(a, b"Visi") == Some(false) {
continue;
}
if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
if let Some((p, _)) = crate::geometry::outline(self, a, class, local) {
mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths);
}
} else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
match crate::raster::mask(self, a, local)? {
Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
} else {
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
}
}
Ok((mask, pixels))
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
self.warn("an artboard without valid bounds (its children are kept without clipping)");
return Kind::Unsupported;
};
let corners =
[Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
let [a, b2, c, d, ..] = world.0;
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)");
}
let xs = corners.map(|p| p.x);
let ys = corners.map(|p| p.y);
let rect = Rect {
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
};
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 {
self.warn("an artboard with an invalid transformed size (its children are kept without clipping)");
return Kind::Unsupported;
}
let class = s.class(id).unwrap_or(Tag::of(b"ShpN"));
let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| {
self.warn("an artboard whose outline could not be read (clipped to its rectangle)");
(Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false)
});
let (background, strokes) = paint::node_paint(self, id, world);
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> =
s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
}
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
}
}
pub(crate) fn union(a: Rect, b: Rect) -> Rect {
Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) }
}
fn is_container(class: Tag) -> bool {
class == Tag::of(b"Scop") || class == Tag::of(b"Grup")
}
pub(crate) fn rect(v: [f64; 4]) -> Rect {
Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] }
}
/// A readable name for a layer class in warnings.
pub(crate) fn describe(class: Tag) -> &'static str {
match &class.0.to_be_bytes() {
b"PCrv" => "curve",
b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape",
b"Comp" => "compound",
b"Rstr" => "pixel",
b"ImgN" => "image",
b"EmbN" => "embedded document",
b"MPIN" => "master page instance",
b"FRst" => "fill",
b"MRst" => "mask",
b"Slic" | b"SlcP" => "slice",
b"3DLA" => "3D",
[_, _, b'R', b'A'] => "adjustment",
_ => "unknown",
}
}
+329
View File
@@ -0,0 +1,329 @@
//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint,
//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index).
use crate::model::{Affine, Reader};
use crate::stream::{ObjId, Value};
/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless
/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
Rgb {
r: f64,
g: f64,
b: f64,
a: f64,
},
Cmyk {
c: f64,
m: f64,
y: f64,
k: f64,
a: f64,
},
Gray {
v: f64,
a: f64,
},
/// CIE L*a*b* (D50).
Lab {
l: f64,
a: f64,
b: f64,
alpha: f64,
},
}
impl Color {
pub fn alpha(&self) -> f64 {
match self {
Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a,
Color::Lab { alpha, .. } => *alpha,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GradientKind {
Linear,
/// Elliptical and radial: distance from the centre in unit space.
Radial,
/// Angle around the centre.
Conical,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stop {
pub offset: f64,
pub color: Color,
/// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend).
pub bias: f64,
}
impl Stop {
/// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve
/// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`.
pub fn half_point(&self) -> f64 {
let k = 1.0 + 8.0 * (self.bias - 0.5).abs();
let h = 0.5f64.powf(1.0 / k);
if self.bias >= 0.5 { h } else { 1.0 - h }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<Stop>,
/// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit
/// circle; conical starts along unit +x and turns towards unit +y.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Paint {
#[default]
None,
Solid(Color),
Gradient(Gradient),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cap {
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Join {
Miter,
Round,
Bevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Center,
Inside,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub paint: Paint,
/// Width in document pixels.
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter_limit: f64,
pub align: Align,
/// Dash and gap lengths in document pixels, and the phase.
pub dash: Option<(Vec<f64>, f64)>,
/// Painted below the fill instead of above it.
pub behind: bool,
}
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
_ => None,
}
}
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
let v = floats(s.field(id, b"_col")?)?;
v.iter().all(|f| f.is_finite()).then_some(v)
};
Some(match &class.0.to_be_bytes() {
b"RGBA" => {
let v = col()?;
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"HSLA" => {
let v = col()?;
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"CMYK" => {
let v = col()?;
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
}
b"GRAY" => {
let v = col()?;
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
}
b"LABA" => {
let b = s.bytes(id, b"_col")?;
let u = |i: usize| -> Option<f64> { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
r.warn("colours in an unknown model");
return None;
}
})
}
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(1.0)) * 6.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r, g, b) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
(r + m, g + m, b + m)
}
/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
/// space to document pixels.
pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Paint> {
let s = r.s;
let (fill, unit) = if s.is(id, b"FDsc") {
let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
(s.obj(id, b"FDeF")?, unit)
} else {
(id, Affine::IDENTITY)
};
match &s.class(fill)?.0.to_be_bytes() {
b"FilN" => Some(Paint::None),
b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
b"FilG" => {
let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
Some(0) | None => GradientKind::Linear,
Some(1 | 2) => GradientKind::Radial,
Some(3) => GradientKind::Conical,
Some(_) => {
r.warn("gradients of an unknown kind (imported as linear)");
GradientKind::Linear
}
};
let grad = s.obj(fill, b"Grad")?;
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
_ => Vec::new(),
};
let mut stops = Vec::new();
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
let color = color(r, *c)?;
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
stops.push(Stop { offset, color, bias });
}
if stops.is_empty() {
return Some(Paint::None);
}
Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
}
b"FilB" => {
r.warn("bitmap and pattern fills (left empty)");
Some(Paint::None)
}
_ => {
r.warn("fills of an unknown kind (left empty)");
Some(Paint::None)
}
}
}
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
let items = r.s.objs(id, list);
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
items.get(i).or_else(|| items.first()).copied()
}
/// The fills of `list` (artboard backgrounds use `BFFl`).
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
}
/// A node's fill and stroke. `local` maps the node's space to document pixels.
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
let s = r.s;
let fills = fills(r, id, local, b"BFFl");
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
r.warn("several fills or strokes on one object (only the active one is imported)");
}
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
r.warn("fill opacity (imported at full fill opacity)");
}
if s.obj(id, b"Trns").is_some() {
r.warn("transparency gradients (imported without them)");
}
let (pen, style) = if s.field(id, b"LIFl").is_some() {
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
} else {
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
};
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
let stroke = match (pen, style) {
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
_ => None,
};
(fills, stroke.into_iter().collect())
}
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
let s = r.s;
let paint = descriptor(r, pen, local)?;
if paint == Paint::None {
return None;
}
let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
// "Scale with object": the weight follows the object's transform; otherwise it is absolute.
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default();
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
// looks wrong.
let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
(Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
(Some(0), Some(_), _) => (Cap::Butt, Join::Round),
(Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
(Some(2), Some(2), _) => (Cap::Square, Join::Round),
_ => (Cap::Round, Join::Round),
};
let align = match s.int(ldsc, b"LDSa") {
Some(1) => Align::Inside,
Some(2) => Align::Outside,
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
v.truncate(v.len() - 2);
}
let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
}
_ => None,
};
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
r.warn("brush and pressure strokes (imported as plain strokes)");
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
}
+124
View File
@@ -0,0 +1,124 @@
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
//! document, at most 512 pixels on the long side in real files, never the document itself.
use crate::Error;
use crate::container::header;
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
#[derive(Debug)]
pub struct Preview<'a> {
pub png: &'a [u8],
pub width: u32,
pub height: u32,
}
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
}
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
}
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
}
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
/// pixels must still be validated by the consuming image decoder, with allocation limits.
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
let h = header(bytes)?;
if h.thumbnail_offset == 0 {
return Err(Error::Unsupported("no embedded preview"));
}
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
if offset < 72 {
return Err(Error::Malformed("thumbnail overlaps the file header"));
}
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
return Err(Error::Malformed("missing indexed thumbnail"));
}
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
return Err(Error::Unsupported("thumbnail record version or encoding"));
}
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
if size > MAX_PREVIEW_BYTES {
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
}
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
}
let png = bytes_at(record, 29, size)?;
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
return Err(Error::Malformed("thumbnail is not a PNG"));
}
let width = be32(png, 16)?;
let height = be32(png, 20)?;
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
return Err(Error::Limit("preview dimensions must be 1..4096"));
}
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
let mut pos = 8usize;
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
loop {
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
let chunk = bytes_at(png, pos, chunk_size)?;
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
return Err(Error::Malformed("PNG chunk checksum"));
}
let kind = bytes_at(chunk, 4, 4)?;
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err(Error::Malformed("PNG chunk type"));
}
match kind {
b"IHDR" if pos == 8 => {}
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
b"PLTE" => {
if palette || image_data {
return Err(Error::Malformed("PNG palette order"));
}
palette = true;
}
b"IDAT" => {
if image_data_ended {
return Err(Error::Malformed("non-contiguous PNG image data"));
}
image_data = true;
}
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
b"IEND" => {}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
_ => {}
}
if image_data && kind != b"IDAT" {
image_data_ended = true;
}
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
}
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
return Err(Error::Unsupported("animated PNG preview"));
}
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
if kind == b"IEND" {
if length != 0 || pos != png.len() {
return Err(Error::Malformed("PNG end or trailing data"));
}
break;
}
}
Ok(Preview { png, width, height })
}
+414
View File
@@ -0,0 +1,414 @@
//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
//! 256 bytes × 256 rows per channel, each its own archive entry.
use std::collections::HashMap;
use crate::Error;
use crate::model::{Affine, Image, Pixels, Reader};
use crate::stream::{self, ObjId, Tag, Value};
/// Pixels decoded per layer (the content's bounding box, not the canvas).
const MAX_PIXELS: u64 = 64 << 20;
const TILE: usize = 256;
const TILE_BYTES: usize = TILE * TILE;
struct CachedPixels {
tiles: HashMap<String, Vec<u8>>,
source: Option<(usize, Vec<u8>)>,
}
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else {
r.warn("pixel layers without pixels");
return Ok(None);
};
if !s.is(bitmap, b"DyBm") {
r.warn("pixel layers stored in an unknown way");
return Ok(None);
}
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
// A placed image: the original file, byte for byte.
let has_tiles = s.field(bitmap, b"Sta1").is_some();
if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
&& (s.is(id, b"ImgN") || !has_tiles)
{
match original(r, name) {
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
}
}
// The content's bounding box, so a mostly empty canvas-sized layer stays small.
let crop = s
.field(id, b"BitR")
.and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
.filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
.map(|v| {
(
v[0].clamp(0, i64::from(width)) as u32,
v[1].clamp(0, i64::from(height)) as u32,
v[2].clamp(0, i64::from(width)) as u32,
v[3].clamp(0, i64::from(height)) as u32,
)
})
.unwrap_or((0, 0, width, height));
let source_tiles_only =
(1..=4).all(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if !a.is_empty() && a.iter().all(|v| *v == Value::UInt(5))));
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !source_tiles_only && !s.is(id, b"ImgN") {
r.warn("edited placed images use their stored pixels");
}
decode(r, bitmap, crop, world)
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None);
}
decode(r, bitmap, (0, 0, width, height), world)
}
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
let [x0, y0, x1, y1] = bbox;
// The instance's origin is the centre of the embedded content (checked against the
// thumbnails of documents with embedded documents and SVG files).
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
decode(r, cache, (0, 0, width, height), place)
}
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let (x0, y0, x1, y1) = crop;
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
if u64::from(w) * u64::from(h) > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
}
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
Some(1) => (4, 2),
Some(6) => (1, 1),
Some(7) => (1, 2),
Some(4) => {
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
(5, 1)
}
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
}
};
let mut planes = Vec::with_capacity(channels);
// Current .af documents can keep level-zero tiles in their embedded JPEG/PNG instead of
// duplicating the pixels. State 5 is checked in the public Patchy embedded-jpeg fixture.
// Support only the observed, same-size RGBA8 image with a zero bitmap origin; other
// representations still receive the existing loss warning rather than guessing a mapping.
let needs_source = (1..=channels).any(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if a.contains(&Value::UInt(5))));
let source = if needs_source && format == Some(0) && s.int(bitmap, b"LInf") == Some(0) && s.int(bitmap, b"TInf") == Some(0) {
if let Some(name) = s.entry(bitmap, b"Bckg") {
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
Ok(pixels) => Some((width as usize, pixels)),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => None,
}
} else {
None
}
} else {
None
};
let mut cache = CachedPixels { tiles: HashMap::new(), source };
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
}
let n = (w as usize) * (h as usize);
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
} else {
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 {
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
let bytes = r.archive.read(name)?;
let s = stream::parse(&bytes)?;
let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
match root.get(Tag::of(b"Data")) {
Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
_ => Err(Error::Malformed("embedded image without data")),
}
}
/// Decode the source under the same pixel budget as tiled layers, checking its actual
/// dimensions before allocating decoded pixels. Only JPEG and PNG sources are supported.
fn source_pixels(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Error> {
use image::{ImageFormat, ImageReader};
use std::io::Cursor;
if u64::from(width) * u64::from(height) > MAX_PIXELS {
return Err(Error::Limit("embedded image pixels"));
}
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Error::Malformed("embedded image format"))?;
if !matches!(reader.format(), Some(ImageFormat::Jpeg | ImageFormat::Png)) {
return Err(Error::Unsupported("embedded source pixel format"));
}
let dimensions = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|_| Error::Malformed("embedded image format"))?
.into_dimensions()
.map_err(|_| Error::Malformed("embedded image dimensions"))?;
if dimensions != (width, height) || width == 0 || height == 0 {
return Err(Error::Malformed("embedded source and bitmap dimensions differ"));
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = Some(MAX_PIXELS * 8);
reader.limits(limits);
let decoded = reader.decode().map_err(|e| match e {
image::ImageError::Limits(_) => Error::Limit("embedded image decoding"),
_ => Error::Malformed("embedded image decoding"),
})?;
Ok(decoded.into_rgba8().into_raw())
}
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
fn plane(
r: &mut Reader,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
cache: &mut CachedPixels,
) -> Result<Vec<u8>, Error> {
let CachedPixels { tiles: cache, source } = cache;
let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
let tiles = s.objs(bitmap, &tag(b"Idx", c));
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) {
let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
}
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
cache.insert(name.clone(), data);
}
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
r.warn("32-bit pixel layers");
None
}
5 => {
if let Some((source_width, pixels)) = source.as_ref() {
let (ox, oy) = (tx * TILE, ty * TILE);
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
});
continue;
}
r.warn("pixel layers drawn from an embedded image (left empty)");
None
}
_ => return Err(Error::Malformed("unknown pixel tile state")),
};
if let Some(v) = fill {
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
}
}
Ok(out)
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
for y in oy.max(py0)..(oy + TILE).min(py1) {
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
*b = at(x - ox, y - oy);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Kind;
use crate::synth::{self, F, Method, tag};
fn png() -> Vec<u8> {
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
pixels.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
bytes.into_inner()
}
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
/// not a claim that Affinity itself accepts the synthetic container.
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
let mut bitmap = vec![
(tag(b"Frmt"), F::Enum(0, 0)),
(tag(b"BmpW"), F::I32(300)),
(tag(b"BmpH"), F::I32(2)),
(tag(b"LInf"), F::I32(origin)),
(tag(b"TInf"), F::I32(0)),
(tag(b"Bckg"), F::Entry("c/1".into())),
];
for c in 1..=4 {
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
bitmap.push((
tag(&super::tag(b"Idx", c)),
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
));
}
let mut crop = vec![0x17];
crop.extend(tag(b"BitR").0.to_le_bytes());
crop.extend([250i32, 0, 270, 2].into_iter().flat_map(i32::to_le_bytes));
let raster = F::Def(
10,
vec![tag(b"Rstr")],
vec![
(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)),
(tag(b"BitR"), F::Raw(crop)),
(tag(b"Xfrm"), F::F64s(vec![0.0, -2.0, 10.0, 2.0, 0.0, 20.0])),
],
);
let spread = F::Def(11, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![raster]))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let mut blob = vec![0x2d];
blob.extend(tag(b"Data").0.to_le_bytes());
blob.extend((source.len() as u32).to_le_bytes());
blob.extend(source);
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
synth::container(
&[
("doc.dat", &doc, Method::Zlib),
("c/1", &block, Method::Zlib),
("d/1", &tiles[0], Method::Zlib),
("d/2", &tiles[1], Method::Zlib),
("d/3", &tiles[2], Method::Zlib),
("d/4", &tiles[3], Method::Zlib),
],
None,
)
}
#[test]
fn source_tiles_and_cached_edits_survive_crop_rotation_and_tile_boundaries() {
let doc = crate::read(&fixture(&png(), 0), crate::Limits::default()).unwrap();
assert_eq!(doc.warnings, ["edited placed images use their stored pixels"]);
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
assert_eq!((image.width, image.height), (20, 2));
assert_eq!(image.transform, Affine([0.0, 2.0, -2.0, 0.0, 10.0, 520.0]));
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
for y in 0..2 {
for x in 0..20 {
let expected = if x + 250 < 256 { [((x + 250) % 251) as u8, (y * 31) as u8, 77, 255] } else { [200, 10, 30, 255] };
assert_eq!(&pixels[(y * 20 + x) * 4..(y * 20 + x + 1) * 4], &expected);
}
}
}
#[test]
fn unreadable_sources_and_unknown_origins_preserve_cached_edits_and_warn() {
for bytes in [fixture(b"invalid image", 0), fixture(&png(), 1)] {
let doc = crate::read(&bytes, crate::Limits::default()).unwrap();
assert!(doc.warnings.iter().any(|w| w == "pixel layers drawn from an embedded image (left empty) (4×)"));
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
assert_eq!(&pixels[..4], &[0, 0, 0, 0]);
assert_eq!(&pixels[6 * 4..7 * 4], &[200, 10, 30, 255]);
}
}
#[test]
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
let bytes = png();
assert!(matches!(source_pixels(&bytes, 299, 2), Err(Error::Malformed(_))));
assert!(matches!(source_pixels(&bytes, u32::MAX, u32::MAX), Err(Error::Limit(_))));
assert_eq!(source_pixels(&bytes, 300, 2).unwrap().len(), 300 * 2 * 4);
}
}
+292
View File
@@ -0,0 +1,292 @@
//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Handle length of a quarter circle of radius 1.
const KAPPA: f64 = 0.552_284_749_830_793_4;
/// Vertices of a polygon or star.
const MAX_SIDES: i64 = 10_000;
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
let t = Tag::of(tag);
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
}
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match last(r, id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
Value::UInt(u) => Some(*u as f64),
Value::Int(i) => Some(*i as f64),
_ => None,
}
}
fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
let n = num(r, id, tag).unwrap_or(default).round();
n.clamp(3.0, MAX_SIDES as f64) as usize
}
fn pt(x: f64, y: f64) -> Point {
Point { x, y }
}
fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
let mut it = pts.into_iter();
let start = it.next()?;
let segments = it.map(|p| [p, p, p]).collect();
Some(SubPath { start, segments, closed: true })
}
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
let [x0, y0, x1, y1] = b;
let (w, h) = (x1 - x0, y1 - y0);
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let types = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
};
let radii = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
// its own radius and type, clockwise from the top left.
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
let i = if locked { 0 } else { i };
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
});
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
} else {
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
r.warn("rectangles with concave or other corner types (imported with round corners)");
}
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
cornered_rect(
b,
corners.map(|(f, t)| {
let (crx, cry) = if !absolute {
(f * w.min(h), f * w.min(h))
} else if sx > 0.0 && sy > 0.0 {
// An absolute radius is in document pixels, whatever the node's scale.
(f / sx, f / sy)
} else {
(0.0, 0.0)
};
(crx, cry, t == Some(1))
}),
)
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
b"ShPy" => {
let n = sides(r, shape, b"Side", 5.0);
let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
// Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
let v = |i: usize| on_ellipse(a(i), 1.0);
let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
let segments = (0..n)
.map(|i| {
let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
[pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
})
.collect();
SubPath { start: v(0), segments, closed: true }
} else {
polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
}
}
b"ShSt" => {
let n = sides(r, shape, b"Pnts", 5.0);
let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("stars with rounded or curved points (imported with sharp points)");
}
polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
}
b"ShSS" => {
let n = sides(r, shape, b"Side", 5.0);
let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
let step = PI / n as f64;
let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
polygon((0..n).flat_map(|k| {
let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
let (c, s) = (phi.cos(), phi.sin());
[(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
}))?
}
b"ShPi" => {
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
// large enough that floating-point addition no longer changes the endpoint.
let start = raw_start.rem_euclid(TAU);
let delta = raw_end - raw_start;
let sweep = if raw_end >= raw_start {
delta.min(TAU)
} else if delta.is_finite() {
delta.rem_euclid(TAU)
} else {
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
};
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, start + sweep, inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
}
b"ShTz" => {
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
}
_ => {
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
ellipse(cx, cy, rx, ry)
}
};
Some(Path { subpaths: vec![sub] })
}
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
}
fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let (kx, ky) = (KAPPA * rx, KAPPA * ry);
SubPath {
start: pt(cx + rx, cy),
segments: vec![
[pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
[pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
[pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
[pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
],
closed: true,
}
}
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
/// one side shrink in proportion until they do.
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
let [tl, tr, br, bl] = corners;
let (w, h) = (x1 - x0, y1 - y0);
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
// (where the corner leaves the previous side, its two handles, where it joins the next side)
let arcs = [
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
];
let mut segments = Vec::with_capacity(8);
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
segments.push([s, s, s]);
if s != e {
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
}
}
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
/// as cubic segments of at most a quarter turn.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = (a1 - a0) / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
for i in 0..n {
let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
}
}
fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
let mut segments = Vec::new();
arc(cx, cy, rx, ry, a0, a1, &mut segments);
if inner > 0.0 {
let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
segments.push([p, p, p]);
arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
} else {
let c = pt(cx, cy);
segments.push([c, c, c]);
}
SubPath { start, segments, closed: true }
}
/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
let n = pts.len();
let get = |i: isize| -> Option<Point> {
if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
};
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
for (i, (p, radius)) in pts.iter().enumerate() {
let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
let (Some(a), Some(b)) = (prev, next) else {
corners.push((*p, *p, *p, *p));
continue;
};
let (u1, l1) = unit(*p, a);
let (u2, l2) = unit(*p, b);
let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
let phi = cos.acos();
if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
corners.push((*p, *p, *p, *p));
continue;
}
let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
let r = t * (phi / 2.0).tan();
let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
let s = pt(p.x + u1.x * t, p.y + u1.y * t);
let e = pt(p.x + u2.x * t, p.y + u2.y * t);
corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
}
let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
let mut segments = Vec::with_capacity(2 * n);
let mut cur = first.0;
let start = cur;
for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
if i > 0 {
segments.push([cur, *s, *s]);
}
if s != e {
segments.push([*c1, *c2, *e]);
}
cur = *e;
}
if closed && cur != start {
segments.push([cur, start, start]);
}
SubPath { start, segments, closed }
}
fn unit(from: Point, to: Point) -> (Point, f64) {
let (dx, dy) = (to.x - from.x, to.y - from.y);
let l = (dx * dx + dy * dy).sqrt();
if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
}
+402
View File
@@ -0,0 +1,402 @@
//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry
//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the
//! document mapping can name what it could not import.
use crate::Error;
use crate::container::Cursor;
/// A four-character tag in its readable order (`Desc`, `Chld`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tag(pub u32);
impl Tag {
pub const fn of(s: &[u8; 4]) -> Self {
Self(u32::from_be_bytes(*s))
}
}
impl std::fmt::Debug for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes()))
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
/// Index of an object in [`Stream::objects`].
pub type ObjId = usize;
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Int(i64),
UInt(u64),
Float(f64),
Bool(bool),
Enum {
id: u16,
version: u16,
},
Str(String),
/// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms).
Ints(Vec<i64>),
Floats(Vec<f64>),
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
Bytes(Vec<u8>),
Blob(Vec<u8>),
/// Name of another archive entry (raster tiles, embedded files).
Entry(String),
Flags(u64),
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Root,
/// Defined once and referenced by id elsewhere.
Shared,
Inline,
/// Untagged fields identified by position (curve data).
Positional,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Object {
pub class: Tag,
pub kind: Kind,
/// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
}
impl Object {
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
}
}
#[derive(Debug)]
pub struct Stream {
pub root: ObjId,
pub objects: Vec<Object>,
}
/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
pub const MAX_DEPTH: usize = 384;
const MAX_OBJECTS: usize = 8_000_000;
/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes
/// in memory however few it took in the file (a bool array packs eight in a byte), so the size
/// of the input alone doesn't bound what parsing allocates: a small compressed entry could
/// otherwise ask for tens of gigabytes. Real documents stay far below.
pub const MAX_VALUES: usize = 1 << 24;
struct Parser<'a> {
c: Cursor<'a>,
objects: Vec<Object>,
/// Shared ids, defined at first use and referenced backwards.
shared: std::collections::HashMap<u32, ObjId>,
depth: usize,
/// Values decoded so far, against [`MAX_VALUES`].
values: usize,
}
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
let mut c = Cursor::new(bytes, 0);
if c.take(4)? != b"\x00\xffKS" {
return Err(Error::Malformed("document stream signature"));
}
let version = c.u16()?;
if version > 2 {
return Err(Error::Unsupported("document stream version"));
}
let class = Tag(c.u32()?);
c.u16()?;
if version == 2 {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.fields = fields;
}
Ok(Stream { root, objects: p.objects })
}
impl Parser<'_> {
fn push(&mut self, o: Object) -> Result<ObjId, Error> {
if self.objects.len() >= MAX_OBJECTS {
return Err(Error::Limit("too many objects"));
}
self.objects.push(o);
Ok(self.objects.len() - 1)
}
/// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them.
fn spend(&mut self, n: usize) -> Result<(), Error> {
self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?;
Ok(())
}
/// An empty array with room for `n` values, paid for first; an allocation that still fails
/// is an error, not an abort.
fn vec<T>(&mut self, n: usize) -> Result<Vec<T>, Error> {
self.spend(n)?;
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
Ok(v)
}
/// Element count of an array: each element takes at least `min` bytes, so a count the
/// remaining input cannot hold is rejected before anything is allocated.
fn count(&mut self, min: usize) -> Result<usize, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
Ok(n)
}
fn fields(&mut self, tagged: bool, class: Tag) -> Result<Vec<(Tag, Value)>, Error> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(Error::Limit("objects nested too deeply"));
}
let mut out = Vec::new();
loop {
let t = self.c.u8()?;
let code = t & 0x7F;
if code == 0 {
break;
}
self.spend(1)?;
let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
out.push((tag, v));
}
self.depth -= 1;
Ok(out)
}
fn string(&mut self) -> Result<String, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
}
fn floats(&mut self, n: usize, wide: bool) -> Result<Value, Error> {
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
}
Ok(Value::Floats(v))
}
fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result<Value, Error> {
let c = &mut self.c;
Ok(match code {
0x01 => Value::UInt(c.u8()?.into()),
0x02 => Value::UInt(c.u16()?.into()),
0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
0x04 => Value::UInt(c.u64()?),
0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
0x15..=0x19 => {
let n = usize::from(code - 0x13);
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(i32::from_le_bytes(c.array()?).into());
}
Value::Ints(v)
}
0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
0x29 => Value::Bool(c.u8()? != 0),
0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
0x2b | 0x2e => Value::Str(self.string()?),
0x2c => {
let n = usize::from(c.u16()?);
Value::Bytes(c.take(n)?.to_vec())
}
0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
0x2d => {
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
Value::Blob(c.take(n)?.to_vec())
}
0x33 => {
c.u32()?;
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
}
0x75 => {
c.u16()?;
let n = usize::from(c.u8()?);
if n > 8 {
return Err(Error::Malformed("flags wider than 64 bits"));
}
let mut b = [0u8; 8];
b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
Value::Flags(u64::from_le_bytes(b))
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
0x31 => self.shared()?,
0x32 => self.inline(None)?,
_ => return Err(Error::Unsupported("unknown field type in document stream")),
})
}
fn array(&mut self, code: u8, class: Tag) -> Result<Value, Error> {
let min = match code {
0x01 | 0x05 | 0x29 => 1,
0x02 | 0x06 => 2,
0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
0x04 | 0x08 | 0x0a => 8,
0x15..=0x19 => 4 * usize::from(code - 0x13),
0x1f..=0x23 => 4 * usize::from(code - 0x1d),
0x24..=0x28 => 8 * usize::from(code - 0x22),
0x35..=0x74 => usize::from(code - 0x34),
_ => 1,
};
match code {
0x29 => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let bits = self.c.take(n.div_ceil(8))?;
let mut v = self.vec(n)?;
v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))));
Ok(Value::Array(v))
}
0x2a => {
let n = self.count(2)?;
let version = self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Enum { id: self.c.u16()?, version });
}
Ok(Value::Array(v))
}
0x2b | 0x2e => {
self.c.u32()?;
let n = self.count(4)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Str(self.string()?));
}
Ok(Value::Array(v))
}
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
}
Ok(Value::Array(v))
}
0x32 => {
let n = self.count(1)?;
let class = Tag(self.c.u32()?);
self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.inline(Some(class))?);
}
Ok(Value::Array(v))
}
0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")),
_ => {
let n = self.count(min)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.scalar(code, true, class)?);
}
Ok(Value::Array(v))
}
}
}
/// Inline object; `class` is given once for a whole array of them.
fn inline(&mut self, class: Option<Tag>) -> Result<Value, Error> {
match self.c.u8()? {
0 => return Ok(Value::Obj(None)),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
let class = match class {
Some(c) => c,
None => {
let c = Tag(self.c.u32()?);
self.c.u16()?;
c
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
Ok(Value::Obj(Some(id)))
}
fn shared(&mut self) -> Result<Value, Error> {
let flag = self.c.u8()?;
if flag == 0 {
return Ok(Value::Obj(None));
}
let key = self.c.u32()?;
match flag {
2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
if self.shared.contains_key(&key) {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
loop {
match self.c.u8()? {
0 => {
let tag = Tag(self.c.u32()?);
self.c.u16()?;
chain.push(tag);
fields.extend(self.fields(true, tag)?);
}
1 => {
chain.push(Tag(self.c.u32()?));
break;
}
2 => break,
_ => return Err(Error::Malformed("object class chain")),
}
if chain.len() > 64 {
return Err(Error::Limit("object class chain too long"));
}
}
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object { class, kind: Kind::Shared, chain, fields };
}
Ok(Value::Obj(Some(id)))
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
//!
//! These builders write the layout the reader accepts, so tests can describe documents in a few
//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::stream::Tag;
/// A field value to encode.
#[derive(Debug, Clone)]
pub enum F {
U8(u8),
U32(u32),
I32(i32),
F32(f32),
F64(f64),
Bool(bool),
Enum(u16, u16),
Str(String),
/// f64 vector of 2..6 values (points, rectangles, transforms).
F64s(Vec<f64>),
/// Fixed-size struct (colour components), 1..64 bytes.
Struct(Vec<u8>),
/// Array of fixed-size records, all `size` bytes.
Records(u16, Vec<Vec<u8>>),
U8s(Vec<u8>),
Entry(String),
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
Ref(u32),
Null,
/// Inline object.
Obj(Tag, Vec<(Tag, F)>),
/// Positional (untagged) object.
Pos(Vec<F>),
/// Array of shared objects / links.
Shared(Vec<F>),
/// Array of inline objects of one class.
Objs(Tag, Vec<Vec<(Tag, F)>>),
/// Raw bytes appended verbatim (malformed-input tests).
Raw(Vec<u8>),
}
pub fn tag(s: &[u8; 4]) -> Tag {
Tag::of(s)
}
fn put_tag(out: &mut Vec<u8>, t: Tag) {
out.extend(t.0.to_le_bytes());
}
fn code(f: &F) -> u8 {
match f {
F::U8(_) => 0x01,
F::U32(_) => 0x03,
F::I32(_) => 0x07,
F::F32(_) => 0x09,
F::F64(_) => 0x0a,
F::Bool(_) => 0x29,
F::Enum(..) => 0x2a,
F::Str(_) => 0x2b,
F::F64s(v) => 0x22 + v.len() as u8,
F::Struct(b) => 0x34 + b.len() as u8,
F::Records(..) => 0x80 | 0x2c,
F::U8s(_) => 0x80 | 0x01,
F::Entry(_) => 0x33,
F::Def(..) | F::Ref(_) => 0x31,
F::Null => 0x31,
F::Obj(..) => 0x32,
F::Pos(_) => 0x30,
F::Shared(_) => 0x80 | 0x31,
F::Objs(..) => 0x80 | 0x32,
F::Raw(_) => 0,
}
}
fn payload(out: &mut Vec<u8>, f: &F) {
match f {
F::U8(v) => out.push(*v),
F::U32(v) => out.extend(v.to_le_bytes()),
F::I32(v) => out.extend(v.to_le_bytes()),
F::F32(v) => out.extend(v.to_le_bytes()),
F::F64(v) => out.extend(v.to_le_bytes()),
F::Bool(v) => out.push(u8::from(*v)),
F::Enum(id, ver) => {
out.extend(id.to_le_bytes());
out.extend(ver.to_le_bytes());
}
F::Str(s) => {
out.extend((s.len() as u32).to_le_bytes());
out.extend(s.as_bytes());
}
F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
F::Struct(b) => out.extend(b),
F::Records(size, recs) => {
out.extend((recs.len() as u32).to_le_bytes());
out.extend(size.to_le_bytes());
recs.iter().for_each(|r| out.extend(r));
}
F::U8s(v) => {
out.extend((v.len() as u32).to_le_bytes());
out.extend(v);
}
F::Entry(name) => {
put_tag(out, tag(b"Data"));
out.extend((name.len() as u32).to_le_bytes());
out.extend(name.as_bytes());
}
F::Def(id, chain, fields) => {
out.push(1);
out.extend(id.to_le_bytes());
// Class chain: each level but the last carries no fields here; the last ends the chain.
let (last, rest) = chain.split_last().expect("class chain");
for t in rest {
out.push(0);
put_tag(out, *t);
out.extend(0u16.to_le_bytes());
out.push(0);
}
out.push(1);
put_tag(out, *last);
fields_into(out, fields, true);
}
F::Ref(id) => {
out.push(2);
out.extend(id.to_le_bytes());
}
F::Null => out.push(0),
F::Obj(class, fields) => {
out.push(1);
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
fields_into(out, fields, true);
}
F::Pos(items) => {
for f in items {
out.push(code(f));
payload(out, f);
}
out.push(0);
}
F::Shared(items) => {
out.extend((items.len() as u32).to_le_bytes());
items.iter().for_each(|f| payload(out, f));
}
F::Objs(class, items) => {
out.extend((items.len() as u32).to_le_bytes());
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
for fields in items {
out.push(1);
fields_into(out, fields, true);
}
}
F::Raw(b) => out.extend(b),
}
}
fn fields_into(out: &mut Vec<u8>, fields: &[(Tag, F)], tagged: bool) {
for (t, f) in fields {
if let F::Raw(b) = f {
out.extend(b);
continue;
}
out.push(code(f));
if tagged {
put_tag(out, *t);
}
payload(out, f);
}
out.push(0);
}
/// A `doc.dat` stream with root class `Pers`.
pub fn stream(root: &[(Tag, F)]) -> Vec<u8> {
let mut out = b"\x00\xffKS".to_vec();
out.extend(2u16.to_le_bytes());
put_tag(&mut out, tag(b"Pers"));
out.extend(0u16.to_le_bytes());
out.extend(30u32.to_le_bytes());
fields_into(&mut out, root, true);
out
}
/// How an entry is stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Stored,
Zlib,
Zstd,
}
/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec<u8> {
let mut out = vec![0u8; 72];
out[..4].copy_from_slice(crate::container::MAGIC);
out[4..6].copy_from_slice(&12u16.to_le_bytes());
out[6..8].copy_from_slice(&4u16.to_le_bytes());
out[8..12].copy_from_slice(b"nsrP");
out[12..16].copy_from_slice(b"#Inf");
out[64..68].copy_from_slice(b"Prot");
let mut records = Vec::new();
for (i, (name, data, method)) in entries.iter().enumerate() {
let packed = match method {
Method::Stored => data.to_vec(),
Method::Zlib => {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(data).unwrap();
e.finish().unwrap()
}
Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
};
let offset = out.len() as u64;
out.extend(b"#Fil");
out.extend(&packed);
let mut r = Vec::new();
r.extend((i as u32 + 1).to_le_bytes());
r.push(0);
r.extend(offset.to_le_bytes());
r.extend((data.len() as u64).to_le_bytes());
r.extend((packed.len() as u64).to_le_bytes());
r.extend(crc32fast::hash(data).to_le_bytes());
r.push(match method {
Method::Stored => 0,
Method::Zlib => 1,
Method::Zstd => 2,
});
r.extend(0u32.to_le_bytes());
r.extend(0u32.to_le_bytes());
r.extend((name.len() as u16).to_le_bytes());
r.extend(name.as_bytes());
records.push(r);
}
let fat = out.len() as u64;
out.extend(b"#FT4");
out.extend(0u64.to_le_bytes());
out.extend(1_700_000_000u64.to_le_bytes());
out.extend([0u8; 24]);
out.extend((records.len() as u32).to_le_bytes());
out.extend([0u8; 8]);
out.extend(0u16.to_le_bytes());
out.push(0);
records.iter().for_each(|r| out.extend(r));
let thumb = out.len() as u64;
out[16..24].copy_from_slice(&fat.to_le_bytes());
if let Some(png) = thumbnail_png {
out[24..32].copy_from_slice(&thumb.to_le_bytes());
out.extend(b"\xff\xff\xff\xffThmb");
out.extend(1u32.to_le_bytes());
out.extend((png.len() as u32 + 13).to_le_bytes());
out.extend(29u32.to_le_bytes());
out.extend(0u32.to_le_bytes());
out.extend((png.len() as u32).to_le_bytes());
out.push(1);
out.extend(png);
}
out
}
+354
View File
@@ -0,0 +1,354 @@
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
use crate::*;
use proptest::prelude::*;
fn fixture() -> Vec<u8> {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend(13u32.to_be_bytes());
png.extend(b"IHDR");
png.extend(2u32.to_be_bytes());
png.extend(1u32.to_be_bytes());
png.extend([8, 6, 0, 0, 0]);
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
png.extend([0; 4]);
png.extend(b"IDAT");
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
png.extend([0; 4]);
png.extend(b"IEND");
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
let mut b = vec![0; 72];
b[..4].copy_from_slice(MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn indexed_preview_and_header() {
let b = fixture();
assert_eq!(header(&b).unwrap().version, 12);
let p = preview(&b).unwrap();
assert_eq!((p.width, p.height), (2, 1));
assert_eq!(p.png, &b[101..]);
}
#[test]
fn every_truncation_is_rejected() {
let b = fixture();
for end in 0..b.len() {
assert!(preview(&b[..end]).is_err(), "{end}");
}
}
#[test]
fn rejects_assets_versions_offsets_lengths_and_bombs() {
let b = fixture();
for (offset, value) in [
(4, 13u64.to_le_bytes().to_vec()),
(8, b"urBR".to_vec()),
(24, u64::MAX.to_le_bytes().to_vec()),
(24, 1u64.to_le_bytes().to_vec()),
(24, 0u64.to_le_bytes().to_vec()),
(84, u32::MAX.to_le_bytes().to_vec()),
(88, 30u32.to_le_bytes().to_vec()),
(96, u32::MAX.to_le_bytes().to_vec()),
(117, 5000u32.to_be_bytes().to_vec()),
] {
let mut broken = b.clone();
broken[offset..offset + value.len()].copy_from_slice(&value);
assert!(preview(&broken).is_err(), "offset {offset}");
}
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
for version in 8..12 {
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
assert!(preview(&legacy).is_ok());
}
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
let mut variant = b.clone();
variant[6..8].copy_from_slice(&flags.to_le_bytes());
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
}
let mut old = b.clone();
old[4..6].copy_from_slice(&7u16.to_le_bytes());
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
}
#[test]
fn an_unindexed_png_or_resource_is_never_used() {
let mut b = fixture();
b[24..32].copy_from_slice(&0u64.to_le_bytes());
assert!(preview(&b).is_err());
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[76..80].copy_from_slice(b"Meta");
assert!(preview(&b).is_err());
}
#[test]
fn every_chunk_checksum_including_the_end_is_checked() {
let b = fixture();
for at in [130, b.len() - 1] {
let mut damaged = b.clone();
damaged[at] ^= 1;
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
let mut ancillary = vec![0; 4];
ancillary.extend(b"tEXt");
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
let mut with_ancillary = b.clone();
let at = b.len() - 12;
with_ancillary.splice(at..at, ancillary);
let png_length = (with_ancillary.len() - 101) as u32;
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
assert!(preview(&with_ancillary).is_ok());
with_ancillary[at + 8] ^= 1;
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
#[test]
fn compressed_metadata_and_animation_are_not_preview_support() {
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
let mut extra = vec![0; 4];
extra.extend(kind);
extra.extend(crc32fast::hash(kind).to_be_bytes());
let mut b = fixture();
let at = b.len() - 12;
b.splice(at..at, extra);
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
}
}
#[test]
fn critical_chunks_follow_a_single_complete_png_layout() {
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
let original = fixture();
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
for kind in kinds {
b.extend([0; 4]);
b.extend(*kind);
b.extend(crc32fast::hash(*kind).to_be_bytes());
}
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
b
}
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
// because its reserved (third-letter) bit is set; future versions can define it.
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
for kinds in [
vec![b"IEND"],
vec![b"IDAT", b"IHDR", b"IEND"],
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
vec![b"IDAT", b"PLTE", b"IEND"],
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
vec![b"ABCD", b"IDAT", b"IEND"],
vec![b"IDAT", b"IEND", b"tEXt"],
] {
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
let _ = header(&b);
let _ = preview(&b);
}
#[test]
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
let mut b = fixture();
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
let _ = preview(&b);
}
}
}
mod archive {
use crate::Error;
use crate::container::{Archive, Limits};
use crate::stream::{self, Kind, Tag, Value};
use crate::synth::{self, F, Method, tag};
use proptest::prelude::*;
fn doc() -> Vec<u8> {
synth::stream(&[
(tag(b"Desc"), F::Str("Document".into())),
(tag(b"Visi"), F::Bool(true)),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
(tag(b"Same"), F::Ref(7)),
(tag(b"Null"), F::Null),
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
(
tag(b"Crvs"),
F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]),
),
(tag(b"Enum"), F::Enum(2, 1)),
(tag(b"Tile"), F::Entry("d/3".into())),
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
])
}
#[test]
fn every_method_round_trips_with_checksums() {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.header.version, 12);
assert_eq!(a.read("doc.dat").unwrap(), d);
assert_eq!(a.read("d/3").unwrap(), b"tile");
assert_eq!(a.extracted(), d.len() + 4);
assert!(a.read("missing").is_err());
}
}
#[test]
fn the_stream_parses_into_shared_objects_and_links() {
let s = stream::parse(&doc()).unwrap();
let root = &s.objects[s.root];
assert_eq!(root.class, Tag::of(b"Pers"));
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
assert_eq!(a, b);
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
assert_eq!(s.objects[*a].kind, Kind::Shared);
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
assert_eq!(kids[0], kids[1]);
let Value::Obj(Some(g)) = kids[0] else { panic!() };
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
}
#[test]
fn checksums_sizes_and_budgets_are_enforced() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
let mut broken = file.clone();
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
let size_at = fat + 59 + 5 + 8;
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
}
#[test]
fn libraries_and_cyclic_tables_are_rejected() {
let d = doc();
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
let mut library = file.clone();
library[8..12].copy_from_slice(b"rAsA");
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
let at = fat as usize + 4;
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
}
#[test]
fn hostile_streams_fail_cleanly() {
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
// An array claiming four billion elements in a few bytes.
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
assert!(stream::parse(&forward).is_err());
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
assert!(stream::parse(&twice).is_err());
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
let d = doc();
for end in 0..d.len() {
assert!(stream::parse(&d[..end]).is_err(), "{end}");
}
}
#[test]
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
// of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An
// array of one-byte values just past the budget fails the same way, before allocating.
let field = |code: u8, n: u32, data: usize| {
let mut f = vec![0x80 | code];
f.extend(tag(b"Big_").0.to_le_bytes());
f.extend(n.to_le_bytes());
f.resize(f.len() + data, 0);
F::Raw(f)
};
let bools = 1u32 << 27;
let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1;
for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] {
let d = synth::stream(&[(tag(b"Big_"), f)]);
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len());
let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap();
assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream"));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let mut file = synth::container(&[("doc.dat", &d, method)], None);
for (i, v) in &edits {
let n = file.len();
file[i % n] = *v;
}
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
let _ = stream::parse(&bytes);
}
}
let mut s = d.clone();
for (i, v) in &edits {
let n = s.len();
s[i % n] = *v;
}
let _ = stream::parse(&s);
}
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Artistic and frame text: the story's characters with their font, size, colour, tracking and
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
//! OpenType features) is left to the importing application.
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
use crate::paint::{self, Color, Paint};
use crate::stream::{ObjId, Value};
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
const MAX_CHARS: usize = 1 << 20;
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let frame = s.obj(id, b"TxtH")?;
let artistic = s.is(frame, b"ArFr");
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
let mut runs = Vec::new();
let mut align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer.
let budget = MAX_CHARS - total;
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
}
total += chars.len();
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
let text = characters(&chars, start, end);
start = end;
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
}
if start < chars.len() {
let text = characters(&chars, start, chars.len());
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
}
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
if let Some(attrs) = s.obj(paragraph, b"Item") {
let paragraph = paragraph_align(r, attrs);
match align {
None => align = Some(paragraph),
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
_ => {}
}
}
}
if truncated {
break;
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
let anchor_x = match align {
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
Align::Right => bounds.x1,
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
r.warn("text frames with a curved outline (imported as rectangular frames)");
}
}
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
}
fn characters(chars: &[char], start: usize, end: usize) -> String {
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
}
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
if text.is_empty() {
return;
}
let run = match attrs {
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
_ => {
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
if attrs.is_none() {
r.warn("text without character attributes (characters preserved with default attributes)");
}
TextRun {
text,
postscript: String::new(),
family: String::new(),
weight: 400,
italic: false,
features: Vec::new(),
all_caps: false,
size: 12.0 * r.dpi / 72.0,
tracking: 0.0,
leading: None,
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
}
}
};
runs.push(run);
}
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
_ => None,
}
}
fn float(v: &Value) -> Option<f64> {
match v {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn int(v: &Value) -> Option<i64> {
match v {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option<usize>) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
let ints = s.field(attrs, b"Ints");
let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
let objects = match s.field(attrs, b"Objs") {
Some(Value::Array(a)) => a.iter().take(8).map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
_ => Vec::new(),
};
let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) {
Some(fill) => fill,
None => {
r.warn("text without readable fill attributes (default black used)");
Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })
}
};
// A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data
// before keeping it; subsequent runs skip attribute decoding once the budget is exhausted.
if let Paint::Gradient(gradient) = &fill {
let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>());
if !charge_attributes(r, attribute_budget, bytes) {
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
}
}
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
{
r.warn("outlined (stroked) text");
}
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
r.warn("horizontally scaled text");
}
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
let requested = s.obj(attrs, b"DFnt");
let resolved =
s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty());
let font = resolved.or(requested);
if let Some(resolved) = resolved {
let differs = [b"Famy", b"Post"]
.iter()
.any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default());
if differs {
r.warn("text uses a stored fallback font (imported with that font)");
}
}
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
r.warn("font width variants (exact face depends on the installed PostScript font)");
}
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
let weight = if (1..=1000).contains(&weight) {
weight
} else {
r.warn("invalid font weight (imported as regular)");
400
};
TextRun {
text,
postscript,
family,
weight,
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
features,
all_caps,
size,
tracking,
leading,
fill,
}
}
fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -> bool {
let Some(remaining) = *budget else { return false };
match remaining.checked_sub(bytes) {
Some(remaining) => {
*budget = Some(remaining);
true
}
None => {
*budget = None;
r.warn("text attribute budget exceeded (excess font names, features and gradients left out)");
false
}
}
}
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
}
/// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in
/// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other
/// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags
fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Option<usize>) -> (Vec<String>, bool) {
let mut features = Vec::new();
let mut all_caps = false;
if attribute_budget.is_none() {
return (features, all_caps);
}
let s = r.s;
let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) };
for (index, setting) in settings.iter().take(257).enumerate() {
if index == 256 {
r.warn("more than 256 text feature settings (truncated)");
break;
}
let Value::Obj(Some(setting)) = setting else {
r.warn("unreadable text feature settings (imported with defaults)");
continue;
};
let setting = *setting;
let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes);
let value = r.s.int(setting, b"Valu");
match (tag, value) {
(Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1),
(Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => {
if !charge_attributes(r, attribute_budget, std::mem::size_of::<String>() + if value == 0 { 5 } else { 4 }) {
break;
}
let name = String::from_utf8_lossy(&tag);
features.push(if value == 0 { format!("-{name}") } else { name.into_owned() });
}
_ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"),
}
}
(features, all_caps)
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("paragraph indents or spacing (imported with defaults)");
}
match slot(r.s.field(p, b"Ints"), 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
Some(0) | None => Align::Left,
Some(_) => {
r.warn("an unknown paragraph alignment (imported as left)");
Align::Left
}
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's
//! accepted schema, without using Affinity software, third-party parser code or external assets.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Document, Limits,
model::{Kind, Path, Point},
synth::{self, F, Method, tag},
};
fn compound(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
}
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(vectorcraft_affinity::stream::Tag, F)>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
)
}
fn read(definitions: Vec<F>, top: u32) -> Document {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
)]),
)],
),
),
]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
vectorcraft_affinity::read(&bytes, Limits::default()).unwrap()
}
fn path(document: &Document) -> &Path {
match &document.spreads[0].nodes[0].kind {
Kind::Shape { path, .. } => path,
other => panic!("expected shape, got {other:?}"),
}
}
fn rejected(document: &Document, warning: &str) {
assert!(matches!(document.spreads[0].nodes[0].kind, Kind::Unsupported));
assert!(document.warnings.iter().any(|value| value.contains(warning)), "{:?}", document.warnings);
}
#[test]
fn self_cycle_and_mutual_cycle_discard_the_entire_compound() {
// A valid first operand ensures a malformed later operand cannot leave a plausible partial path.
let rectangle = shape(100, b"ShNR", vec![]);
let self_cycle = compound(101, vec![F::Ref(100), F::Ref(101)]);
rejected(&read(vec![rectangle.clone(), self_cycle], 101), "compound outline that contains itself");
let mutual_cycle = compound(101, vec![F::Ref(100), compound(102, vec![F::Ref(101)])]);
rejected(&read(vec![rectangle, mutual_cycle], 101), "compound outline that contains itself");
}
#[test]
fn deep_reference_chain_is_bounded_independently_of_stream_nesting() {
let mut definitions = vec![shape(100, b"ShNR", vec![])];
for id in 101..=250 {
definitions.push(compound(id, vec![F::Ref(id - 1)]));
}
rejected(&read(definitions, 250), "compound outlines nested deeper than 128 levels");
}
#[test]
fn outline_at_the_depth_limit_is_still_imported() {
let mut definitions = vec![shape(100, b"ShNR", vec![])];
for id in 101..=227 {
definitions.push(compound(id, vec![F::Ref(id - 1)]));
}
let document = read(definitions, 227);
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
assert_eq!(path(&document).subpaths.len(), 1);
assert_eq!(path(&document).subpaths[0].segments.len(), 3);
}
#[test]
fn excessive_declared_curve_count_is_rejected_before_expansion() {
let curve = F::Def(
100,
vec![tag(b"PCrv")],
vec![(tag(b"Crvs"), F::Def(101, vec![tag(b"PCvD")], vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(u32::MAX)]))]))],
);
rejected(&read(vec![curve], 100), "compound outlines exceeding the geometry work limit");
}
#[test]
fn empty_shared_fanout_cannot_bypass_the_operand_visit_limit() {
// Only 21 on-disk objects, but an unbounded traversal would visit over two million operands.
let mut definitions = vec![compound(100, vec![])];
for id in 101..=120 {
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
}
rejected(&read(definitions, 120), "compound outlines exceeding the operand visit limit");
}
#[test]
fn shared_large_operands_are_charged_for_cumulative_geometry_work() {
// A small reference graph expands a 20,000-segment star. Count work across every reuse and
// transform, rather than permitting each individual operand's allocation limit repeatedly.
let mut definitions = vec![shape(100, b"ShSt", vec![(tag(b"Pnts"), F::I32(10_000))])];
for id in 101..=112 {
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
}
rejected(&read(definitions, 112), "compound outlines exceeding the geometry work limit");
}
#[test]
fn ordinary_nested_compound_preserves_repeated_operands_and_transforms() {
let mut rectangle = shape(100, b"ShNR", vec![]);
if let F::Def(_, _, fields) = &mut rectangle {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 3.0, 0.0, 1.0, 7.0])));
}
let inner = compound(101, vec![F::Ref(100), F::Ref(100)]);
let mut outer = compound(102, vec![F::Ref(101)]);
if let F::Def(_, _, fields) = &mut outer {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 200.0])));
}
let document = read(vec![rectangle, inner, outer], 102);
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
let path = path(&document);
assert_eq!(path.subpaths.len(), 2);
assert_eq!(path.subpaths[0], path.subpaths[1]);
assert_eq!(path.subpaths[0].start, Point { x: 103.0, y: 207.0 });
assert_eq!(path.subpaths[0].segments.last().unwrap()[2], Point { x: 103.0, y: 227.0 });
}
#[test]
fn overflowing_compound_transform_is_reported_before_processing_geometry() {
let mut child = shape(100, b"ShNR", vec![]);
if let F::Def(_, _, fields) = &mut child {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
}
let mut outer = compound(101, vec![F::Ref(100)]);
if let F::Def(_, _, fields) = &mut outer {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
}
rejected(&read(vec![child, outer], 101), "compound operand with an invalid transform");
}
#[test]
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
for subpath in &path(&document).subpaths {
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
assert!(subpath.segments.len() <= 20);
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
}
}
#[test]
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
fn corner_types(ids: [u16; 4]) -> F {
let mut raw = vec![0xaa];
raw.extend(tag(b"CTyp").0.to_le_bytes());
raw.extend(4u32.to_le_bytes());
raw.extend(0u16.to_le_bytes());
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
F::Raw(raw)
}
#[test]
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
let p = |x: f64, y: f64| Point { x, y };
// 10×20 box: relative radii scale by 10. Round 5, round 1, straight 3, none.
let corners = |lock: Option<bool>| {
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
read(vec![shape(100, b"ShNR", fields)], 100)
};
let unlocked = corners(Some(false));
assert!(unlocked.warnings.is_empty(), "{:?}", unlocked.warnings);
let sub = &path(&unlocked).subpaths[0];
assert_eq!(sub.start, p(5.0, 0.0));
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
// Locked (the default): every corner follows the top left one.
let locked = corners(None);
let sub = &path(&locked).subpaths[0];
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
+116
View File
@@ -0,0 +1,116 @@
//! Pixel retention on current Affinity 3 documents published under MIT by Patchy.
//! Assertions use this reader's archive/stream and the original embedded image bytes;
//! no third-party Affinity parser, source code or application is used as an oracle.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Archive, Limits,
model::{Affine, Document, Image, Kind, Node, Pixels},
stream,
};
#[path = "support/corpus.rs"]
mod corpus;
fn images(nodes: &[Node], out: &mut Vec<Image>) {
for node in nodes {
if let Kind::Image(image) = &node.kind {
out.push(image.clone());
}
images(&node.children, out);
}
}
fn read_fixture(name: &str) -> Option<(Vec<u8>, Document, Vec<Image>)> {
let dir = corpus::pinned()?;
let bytes = std::fs::read(dir.join(name)).unwrap();
let doc = vectorcraft_affinity::read(&bytes, Limits::default()).unwrap();
assert!(doc.saved_by.as_deref().unwrap().starts_with("Affinity 3."));
let mut out = Vec::new();
for spread in &doc.spreads {
images(&spread.nodes, &mut out);
}
Some((bytes, doc, out))
}
fn source(bytes: &[u8]) -> Vec<u8> {
let mut archive = Archive::open(bytes, Limits::default()).unwrap();
let doc = stream::parse(&archive.read("doc.dat").unwrap()).unwrap();
let bitmap =
doc.objects.iter().enumerate().find(|(_, o)| o.class == stream::Tag::of(b"DyBm") && o.get(stream::Tag::of(b"Bckg")).is_some()).unwrap().0;
let name = doc.entry(bitmap, b"Bckg").unwrap();
let block = stream::parse(&archive.read(name).unwrap()).unwrap();
block.bytes(block.root, b"Data").unwrap().to_vec()
}
#[test]
fn source_backed_tiles_retain_the_embedded_jpeg_pixels() {
let Some((bytes, doc, images)) = read_fixture("patchy-embedded-jpeg.af") else { return };
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
assert_eq!(images.len(), 1);
let image = &images[0];
assert_eq!((image.width, image.height), (400, 300));
assert_eq!(image.transform, Affine::IDENTITY);
let Pixels::Rgba8(pixels) = &image.pixels else { panic!("expected retained level-zero pixels") };
let expected = image::load_from_memory(&source(&bytes)).unwrap().into_rgba8();
assert_eq!(pixels.as_slice(), expected.as_raw());
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1]));
}
#[test]
fn lazy_placed_images_keep_the_original_encoded_bytes() {
let Some((bytes, doc, images)) = read_fixture("patchy-lazy-placed.af") else { return };
assert!(doc.warnings.is_empty());
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (400, 300));
assert_eq!(images[0].transform, Affine::IDENTITY);
assert_eq!(images[0].pixels, Pixels::Encoded(source(&bytes)));
}
#[test]
fn eight_and_sixteen_bit_channels_retain_the_same_pixel_pattern() {
let Some((_, doc8, images8)) = read_fixture("patchy-rgba8.af") else { return };
let (_, doc16, images16) = read_fixture("patchy-rgba16.af").unwrap();
assert!(doc8.warnings.is_empty());
assert!(doc16.warnings.is_empty());
assert_eq!(images8.len(), 1);
assert_eq!(images16, images8);
assert_eq!((images8[0].width, images8[0].height), (64, 48));
let Pixels::Rgba8(pixels) = &images8[0].pixels else { panic!() };
assert_eq!(pixels.len(), 64 * 48 * 4);
assert_eq!(&pixels[..4], &[0, 0, 0, 128]);
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] < 255));
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1]));
// Frozen pixel-retention baseline, including every alpha sample. It is not a render oracle.
assert_eq!(crc32fast::hash(pixels), 0xc8ee4dc1);
}
#[test]
fn rotated_scaled_pixels_keep_the_full_affine_transform() {
let Some((_, doc, images)) = read_fixture("patchy-transform.af") else { return };
assert!(doc.warnings.is_empty());
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (80, 60));
let expected = [1.1742158910592235, 0.4286222593193142, -0.4286222593193142, 1.1742158910592235, 60.0, 40.0];
for (actual, expected) in images[0].transform.0.into_iter().zip(expected) {
assert!((actual - expected).abs() < 1e-12);
}
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(pixels.len(), 80 * 60 * 4);
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
assert_eq!(crc32fast::hash(pixels), 0x3720a442);
}
#[test]
fn cmyk_and_lab_losses_are_reported() {
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (64, 48));
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
assert!(images.is_empty());
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
}
+115
View File
@@ -0,0 +1,115 @@
//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
//! `AFFINITY_SAMPLES` (searched recursively). CI requires a complete, checksum-verified pinned
//! corpus via `AFFINITY_CORPUS_REQUIRED=1`; ordinary local runs may skip an absent corpus.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use vectorcraft_affinity::{Archive, Limits, stream};
#[path = "support/corpus.rs"]
mod corpus;
#[path = "support/features.rs"]
mod features;
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
files(&p, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e)) {
out.push(p);
}
}
}
fn samples() -> Option<PathBuf> {
let pinned = corpus::pinned();
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or(pinned)
}
#[test]
fn public_documents_parse_completely() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
let mut failures = Vec::new();
for p in &paths {
let bytes = std::fs::read(p).unwrap();
let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| {
let doc = a.read("doc.dat")?;
stream::parse(&doc).map(|s| s.objects.len())
});
match result {
Ok(n) => assert!(n > 0),
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n"));
eprintln!("{} Affinity documents parsed", paths.len());
}
#[test]
fn public_documents_map_to_the_model() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
let mut warnings = std::collections::BTreeMap::<String, usize>::new();
let mut failures = Vec::new();
let (mut nodes, mut docs) = (0usize, 0usize);
fn count(n: &[vectorcraft_affinity::model::Node]) -> usize {
n.iter().map(|n| 1 + count(&n.children)).sum()
}
for p in &paths {
let bytes = std::fs::read(p).unwrap();
match vectorcraft_affinity::read(&bytes, Limits::default()) {
Ok(d) => {
docs += 1;
nodes += d.spreads.iter().map(|s| count(&s.nodes)).sum::<usize>();
for w in d.warnings {
let key = w.split(" (").next().unwrap_or(&w).to_string();
*warnings.entry(key).or_default() += 1;
}
}
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
eprintln!("{docs} documents, {nodes} nodes");
for (w, n) in &warnings {
eprintln!("{n:4} documents: {w}");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Measured structural baselines for actual Affinity 3 files. Unlike a parse-success test,
/// this detects disappearing nodes, curves, runs, font names, artboards and loss warnings.
/// It does not claim that an unsupported feature is faithfully rendered.
#[test]
fn affinity3_feature_retention_matches_pinned_signatures() {
let Some(dir) = corpus::pinned() else { return };
let expected = include_str!("support/affinity3-signatures.txt");
let mut checked = std::collections::BTreeSet::new();
for line in expected.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
let (name, signature) = line.split_once('\t').expect("signature row must have a tab");
assert!(checked.insert(name), "duplicate fixture {name}");
let bytes = std::fs::read(dir.join(name)).unwrap();
let doc = vectorcraft_affinity::read(&bytes, Limits::default()).unwrap();
assert_eq!(
features::signature(&doc),
signature,
"{name}: imported feature retention changed; review the pinned input and warning changes before updating this baseline"
);
assert!(
doc.saved_by.as_deref().is_some_and(|v| v.starts_with("Affinity 3.")),
"{name}: expected an actual Affinity 3 document, got {:?}",
doc.saved_by
);
}
let manifest = include_str!("../../../xtask/affinity-corpus.sha256");
let af_names: std::collections::BTreeSet<_> =
manifest.lines().filter_map(|l| l.split_once(" ")).map(|(_, n)| n).filter(|n| n.ends_with(".af")).collect();
assert_eq!(checked, af_names, "every pinned .af file needs a reviewed structural baseline");
}
@@ -0,0 +1,33 @@
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
+30
View File
@@ -0,0 +1,30 @@
//! Shared pinned corpus gate: an installed corpus must be complete and unmodified.
#![allow(dead_code)]
use std::path::PathBuf;
#[path = "../../../../xtask/src/sha256.rs"]
mod sha256;
pub fn pinned() -> Option<PathBuf> {
// This helper is compiled in both affinity and engine integration tests.
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
let required = std::env::var_os("AFFINITY_CORPUS_REQUIRED").is_some_and(|v| v != "0");
if !dir.exists() {
assert!(!required, "required Affinity corpus absent: run `cargo xtask corpus --affinity`");
eprintln!("Affinity corpus absent: run `cargo xtask corpus --affinity` (CI sets AFFINITY_CORPUS_REQUIRED=1)");
return None;
}
assert!(dir.join("SOURCES.md").is_file(), "incomplete Affinity corpus: missing SOURCES.md; run `cargo xtask corpus --affinity`");
let manifest = include_str!("../../../../xtask/affinity-corpus.sha256");
let mut count = 0;
for line in manifest.lines().filter(|l| !l.trim().is_empty()) {
let (digest, name) = line.split_once(" ").expect("malformed pinned Affinity SHA256 manifest");
assert_eq!(digest.len(), 64, "invalid SHA256 for {name}");
let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("missing pinned Affinity file {name}: {e}"));
assert_eq!(sha256::hex(&bytes), digest, "{name}: pinned corpus modified; run `cargo xtask corpus --affinity`");
count += 1;
}
assert!(count >= 4, "pinned Affinity manifest must include the four Affinity 3 .af fixtures");
Some(dir)
}
+63
View File
@@ -0,0 +1,63 @@
//! Structural regression signatures, measured from the pinned files through our own reader.
//! These freeze feature retention; they are not independent visual or compatibility oracles.
//! The model CRC covers every decoded field via its Debug representation (including paths,
//! paint, masks, image pixels and text runs). A deliberate reader/Debug change needs review;
//! input authenticity is independently checked with SHA256 by the corpus gate.
use std::collections::{BTreeMap, BTreeSet};
use vectorcraft_affinity::model::{Document, Kind, Node};
use vectorcraft_affinity::paint::Paint;
pub fn signature(doc: &Document) -> String {
fn visit(nodes: &[Node], counts: &mut BTreeMap<&'static str, usize>, fonts: &mut BTreeSet<String>, boards: &mut Vec<String>, depth: usize) {
for n in nodes {
*counts.entry("nodes").or_default() += 1;
counts.entry("depth").and_modify(|v| *v = (*v).max(depth)).or_insert(depth);
let key = match &n.kind {
Kind::Layer => "layers",
Kind::Group => "groups",
Kind::Artboard { rect, .. } => {
boards.push(format!("{}:{:.4},{:.4},{:.4},{:.4}", n.name, rect.x0, rect.y0, rect.x1, rect.y1));
"artboards"
}
Kind::Shape { path, fills, strokes, .. } => {
*counts.entry("subpaths").or_default() += path.subpaths.len();
*counts.entry("segments").or_default() += path.subpaths.iter().map(|p| p.segments.len()).sum::<usize>();
*counts.entry("fills").or_default() += fills.len();
*counts.entry("strokes").or_default() += strokes.len();
*counts.entry("gradients").or_default() += fills.iter().filter(|p| matches!(p, Paint::Gradient(_))).count();
"shapes"
}
Kind::Text(t) => {
*counts.entry("text_runs").or_default() += t.runs.len();
*counts.entry("text_chars").or_default() += t.runs.iter().map(|r| r.text.chars().count()).sum::<usize>();
for r in &t.runs {
fonts.insert(format!("{}:{}:{}:{}", r.family, r.postscript, r.weight, r.italic));
}
"texts"
}
Kind::Image(_) => "images",
Kind::Unsupported => "unsupported",
};
*counts.entry(key).or_default() += 1;
for (key, yes) in
[("hidden", !n.visible), ("vector_masks", n.mask.is_some()), ("pixel_masks", n.pixel_mask.is_some()), ("opacity", n.opacity < 1.0)]
{
if yes {
*counts.entry(key).or_default() += 1;
}
}
visit(&n.children, counts, fonts, boards, depth + 1);
}
}
let (mut counts, mut fonts, mut boards) = (BTreeMap::new(), BTreeSet::new(), Vec::new());
for spread in &doc.spreads {
visit(&spread.nodes, &mut counts, &mut fonts, &mut boards, 1);
}
let model_crc32 = crc32fast::hash(format!("{:?}", doc.spreads).as_bytes());
format!(
"dpi={};spreads={};model_crc32={model_crc32:08x};counts={counts:?};boards={boards:?};fonts={fonts:?};warnings={:?}",
doc.dpi,
doc.spreads.len(),
doc.warnings
)
}
@@ -0,0 +1,170 @@
//! Original synthetic reader fixtures, shared by native and engine regression tests.
//! These describe the reader's existing schema; no Affinity software or third-party parser was
//! used to create them. They prove recovery/mapping behavior, not Affinity's layout or exporter.
#![allow(dead_code)]
use vectorcraft_affinity::synth::{self, F, Method, tag};
pub fn array_f64(field: &[u8; 4], values: &[f64]) -> F {
let mut bytes = vec![0x8a];
bytes.extend(tag(field).0.to_le_bytes());
bytes.extend((values.len() as u32).to_le_bytes());
bytes.extend(values.iter().flat_map(|v| v.to_le_bytes()));
F::Raw(bytes)
}
pub fn array_i32(field: &[u8; 4], values: &[i32]) -> F {
let mut bytes = vec![0x87];
bytes.extend(tag(field).0.to_le_bytes());
bytes.extend((values.len() as u32).to_le_bytes());
bytes.extend(values.iter().flat_map(|v| v.to_le_bytes()));
F::Raw(bytes)
}
pub fn solid(alpha: f32) -> F {
F::Obj(
tag(b"FilS"),
vec![(
tag(b"Colr"),
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, alpha].iter().flat_map(|v| v.to_le_bytes()).collect()))]),
)],
)
}
pub fn attrs(family: &str, postscript: &str, weight: i32, italic: bool, size: f64) -> F {
let mut doubles = vec![0.0; 14];
doubles[0] = size;
doubles[4] = 0.025;
doubles[10] = 1.0;
doubles[13] = 30.0;
F::Obj(
tag(b"GAtt"),
vec![
(
tag(b"DFnt"),
F::Obj(
tag(b"Font"),
vec![
(tag(b"Famy"), F::Str(family.into())),
(tag(b"Post"), F::Str(postscript.into())),
(tag(b"Wegt"), F::I32(weight)),
(tag(b"Ital"), F::Bool(italic)),
],
),
),
(tag(b"Doub"), array_f64(b"Doub", &doubles)),
(tag(b"Ints"), array_i32(b"Ints", &[0, 0, 0, 0, 1])),
(tag(b"Objs"), F::Shared(vec![solid(1.0)])),
],
)
}
pub fn run(end: i32, attrs: Option<F>) -> Vec<(vectorcraft_affinity::stream::Tag, F)> {
let mut fields = vec![(tag(b"Indx"), F::I32(end))];
if let Some(attrs) = attrs {
fields.push((tag(b"Item"), attrs));
}
fields
}
pub fn block(glyphs: F, runs: Vec<Vec<(vectorcraft_affinity::stream::Tag, F)>>, alignments: &[i32]) -> F {
F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), glyphs),
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), runs))])),
(
tag(b"PAtt"),
F::Obj(
tag(b"PaAS"),
vec![(
tag(b"Runs"),
F::Objs(
tag(b"PaAR"),
alignments
.iter()
.map(|a| vec![(tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[*a]))]))])
.collect(),
),
)],
),
),
],
)
}
pub fn utf8(text: &str) -> F {
F::Obj(tag(b"GStr"), vec![(tag(b"Utf8"), F::Str(text.into()))])
}
pub fn document(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec<u8> {
let text = F::Obj(
tag(if frame { b"TxtF" } else { b"TxtA" }),
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])),
(
tag(b"TxtH"),
F::Obj(
tag(if frame { b"FrFr" } else { b"ArFr" }),
vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))],
),
),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
],
);
let mut root = vec![
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])),
(
tag(b"DocR"),
F::Obj(
tag(b"DocN"),
vec![(
tag(b"Chld"),
F::Shared(vec![F::Obj(
tag(b"Sprd"),
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![text]))],
)]),
)],
),
),
];
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
let stream = synth::stream(&root);
synth::container(&[("doc.dat", &stream, Method::Zlib)], None)
}
pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
// fixture objects independent shared IDs without making the fixture descriptions noisy.
fn shared_objects(value: &mut F, next_id: &mut u32) {
match value {
F::Shared(items) => {
for item in items {
if let F::Obj(class, fields) = item {
let class = *class;
let fields = std::mem::take(fields);
*next_id += 1;
*item = F::Def(*next_id, vec![class], fields);
}
shared_objects(item, next_id);
}
}
F::Obj(_, fields) | F::Def(_, _, fields) => {
for (_, value) in fields {
shared_objects(value, next_id);
}
}
F::Objs(_, items) => {
for fields in items {
for (_, value) in fields {
shared_objects(value, next_id);
}
}
}
_ => {}
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance.
//! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3
//! mixed-run fixture. Broad complex typography still needs independent visual validation.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod support {
pub mod corpus;
pub mod text_fixture;
}
use support::text_fixture::*;
use vectorcraft_affinity::{
Document, Limits,
model::{Align, Kind, Text},
paint::{Color, Paint},
synth::{F, tag},
};
fn read(blocks: Vec<F>) -> Document {
vectorcraft_affinity::read(&document(blocks, false, 96.0, IDENTITY), Limits::default()).unwrap()
}
fn text(d: &Document) -> &Text {
match &d.spreads[0].nodes[0].kind {
Kind::Text(t) => t,
other => panic!("expected text, got {other:?}"),
}
}
fn content(t: &Text) -> String {
t.runs.iter().map(|r| r.text.as_str()).collect()
}
fn warning(d: &Document, needle: &str) -> bool {
d.warnings.iter().any(|w| w.contains(needle))
}
#[test]
fn mixed_fonts_keep_unicode_and_attribute_boundaries() {
let d = read(vec![block(
utf8("Aé😀e\u{301}אב日本\u{2028}x\u{2029}\0"),
vec![
run(3, Some(attrs("Family One", "FamilyOne-Regular", 400, false, 16.0))),
run(9, Some(attrs("Family Two", "FamilyTwo-BoldItalic", 700, true, 24.0))),
run(13, Some(attrs("Family Three", "FamilyThree-Light", 300, false, 20.0))),
],
&[1],
)]);
let t = text(&d);
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["Aé😀", "e\u{301}אב日本", "\u{2028}x\u{2029}"]);
assert_eq!(content(t), "Aé😀e\u{301}אב日本\u{2028}x\u{2029}");
assert_eq!(
(t.runs[1].family.as_str(), t.runs[1].postscript.as_str(), t.runs[1].weight, t.runs[1].italic),
("Family Two", "FamilyTwo-BoldItalic", 700, true)
);
assert_eq!((t.runs[1].size, t.runs[1].tracking, t.runs[1].leading), (24.0, 0.025, Some(30.0)));
assert!(matches!(t.runs[1].fill, Paint::Solid(Color::Rgb { a: 1.0, .. })));
assert_eq!(t.align, Align::Center);
assert_eq!((t.anchor.x, t.anchor.y), (110.0, 38.0));
assert!(d.warnings.is_empty(), "{:?}", d.warnings);
}
#[test]
fn missing_attributes_and_uncovered_tail_preserve_characters() {
for runs in [vec![], vec![run(2, None)], vec![run(2, Some(attrs("First", "", 400, false, 14.0)))]] {
let d = read(vec![block(utf8("é😀 tail\0"), runs, &[])]);
assert_eq!(content(text(&d)), "é😀 tail");
assert!(warning(&d, "text without character attributes"));
assert!(matches!(text(&d).runs.last().unwrap().fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })));
assert_eq!(text(&d).runs.last().unwrap().size, 16.0); // Recovery default: 12 pt at 96 dpi.
}
}
#[test]
fn malformed_ranges_do_not_discard_the_story() {
let mut missing = run(1, None);
missing.clear();
let d = read(vec![block(
utf8("abcdef"),
vec![missing, run(-1, None), run(3, Some(attrs("First", "", 400, false, 10.0))), run(2, None), run(i32::MAX, None)],
&[],
)]);
assert_eq!(content(text(&d)), "abcdef");
assert_eq!(text(&d).runs[0].text, "abc");
assert!(warning(&d, "invalid text attribute range"));
}
#[test]
fn inline_fields_count_for_ranges_but_are_reported_and_omitted() {
let glyphs = F::Obj(
tag(b"GStr"),
vec![(
tag(b"Mixd"),
F::Objs(
tag(b"MixS"),
vec![
vec![(tag(b"Glys"), F::Shared(vec![F::Obj(tag(b"FldN"), vec![])])), (tag(b"Utf8"), F::Str("é😀".into()))],
vec![(tag(b"Utf8"), F::Str("尾\0".into()))],
],
),
)],
);
let d =
read(vec![block(glyphs, vec![run(3, Some(attrs("First", "", 400, false, 10.0))), run(5, Some(attrs("Second", "", 700, false, 15.0)))], &[])]);
assert_eq!(text(&d).runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["é😀", "尾"]);
assert!(warning(&d, "text fields such as page numbers"));
}
#[test]
fn mixed_paragraph_alignments_are_explicitly_reported() {
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
assert_eq!(content(text(&d)), "one\u{2029}two");
assert_eq!(text(&d).align, Align::Right);
assert!(warning(&d, "mixed paragraph alignments"));
}
#[test]
fn unknown_alignment_and_invalid_font_weight_have_honest_fallbacks() {
let d = read(vec![block(utf8("test"), vec![run(4, Some(attrs("", "OnlyPostScript", -50, true, f64::NAN)))], &[99])]);
assert_eq!((text(&d).runs[0].weight, text(&d).runs[0].size), (400, 16.0));
assert_eq!(text(&d).align, Align::Left);
assert!(warning(&d, "invalid font weight"));
assert!(warning(&d, "unknown paragraph alignment"));
}
#[test]
fn unreadable_block_does_not_remove_later_readable_text() {
let d = read(vec![F::Obj(tag(b"StBl"), vec![]), block(utf8("still here"), vec![], &[])]);
assert_eq!(content(text(&d)), "still here");
assert!(warning(&d, "a text block without readable characters"));
}
#[test]
fn text_budget_preserves_prefix_and_reports_truncation() {
let d = read(vec![block(utf8(&"é".repeat((1 << 20) + 1)), vec![], &[]), block(utf8("later"), vec![], &[])]);
assert_eq!(content(text(&d)).chars().count(), 1 << 20);
assert!(content(text(&d)).chars().all(|c| c == 'é'));
assert!(warning(&d, "text longer than a million characters"));
}
#[test]
fn boolean_opentype_and_observed_all_caps_selector_are_kept() {
let mut a = attrs("Family", "", 400, false, 12.0);
let settings = F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
vec![
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"salt"))), (tag(b"Valu"), F::I32(2))],
],
),
)],
);
if let F::Obj(_, fields) = &mut a {
let objects = fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap();
objects.1 = F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
}
let d = read(vec![block(utf8("Mixed"), vec![run(5, Some(a))], &[])]);
assert_eq!(text(&d).runs[0].features, ["smcp", "-liga"]);
assert!(text(&d).runs[0].all_caps);
assert_eq!(content(text(&d)), "Mixed");
assert!(warning(&d, "non-boolean OpenType settings"));
}
#[test]
fn stored_fallback_font_is_used_only_when_it_names_a_font() {
for (family, expected) in [("", "Requested"), ("Emoji Family", "Emoji Family")] {
let mut a = attrs("Requested", "RequestedPS", 700, true, 12.0);
if let F::Obj(_, fields) = &mut a {
fields.push((tag(b"RFnt"), F::Obj(tag(b"Font"), vec![(tag(b"Famy"), F::Str(family.into())), (tag(b"Wegt"), F::I32(400))])));
}
let d = read(vec![block(utf8("😀"), vec![run(1, Some(a))], &[])]);
assert_eq!(text(&d).runs[0].family, expected);
assert_eq!(warning(&d, "stored fallback font"), !family.is_empty());
}
}
#[test]
fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
let Some(corpus) = support::corpus::pinned() else { return };
// Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser
// source was read; assertions describe this crate's parsing of its published MIT documents.
let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap();
let d = load("patchy-text-runs.af");
let t = text(&d);
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["AB", "cd", "É", "😀", "X"]);
assert_eq!(t.runs[3].family, "Segoe UI Emoji");
assert_eq!(t.runs[4].family, "Courier New");
assert!(warning(&d, "stored fallback font"));
let d = load("patchy-text-caps.af");
let texts: Vec<_> = d.spreads[0].nodes.iter().filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None }).collect();
assert_eq!(texts.len(), 2);
assert!(texts[0].runs[0].all_caps);
assert_eq!(texts[1].runs[0].features, ["smcp"]);
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
let d = load(name);
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
}
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
fn arbitrary_unicode_survives_damaged_attribute_ranges(
chars in proptest::collection::vec(proptest::prelude::any::<char>(), 0..128),
ends in proptest::collection::vec(-10i32..200, 0..32),
) {
let original: String = chars.into_iter().collect();
let expected: String = original.chars().filter(|c| *c != '\0' && *c != '\u{FFFC}').collect();
let d = read(vec![block(utf8(&original), ends.into_iter().map(|end| run(end, None)).collect(), &[])]);
proptest::prop_assert_eq!(content(text(&d)), expected);
}
}
#[test]
fn partially_missing_attributes_use_visible_fill_but_explicit_no_fill_is_kept() {
let empty = F::Obj(tag(b"GAtt"), vec![]);
let none = F::Obj(tag(b"GAtt"), vec![(tag(b"Objs"), F::Shared(vec![F::Obj(tag(b"FilN"), vec![])]))]);
let d = read(vec![block(utf8("ab"), vec![run(1, Some(empty)), run(2, Some(none))], &[])]);
assert!(matches!(text(&d).runs[0].fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })));
assert_eq!(text(&d).runs[1].fill, Paint::None);
assert_eq!(content(text(&d)), "ab");
assert!(warning(&d, "without readable fill attributes"));
}
#[test]
fn excessive_feature_settings_are_bounded_and_reported() {
let mut a = attrs("Family", "", 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
let settings = F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
(0..257).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(),
),
)],
);
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 =
F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
}
let d = read(vec![block(utf8("x"), vec![run(1, Some(a))], &[])]);
assert_eq!(text(&d).runs[0].features.len(), 256);
assert!(warning(&d, "more than 256 text feature settings"));
}
#[test]
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
let settings = F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
(0..256).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(),
),
)],
);
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 =
F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
}
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
let metadata: usize = t
.runs
.iter()
.flat_map(|r| [&r.family, &r.postscript].into_iter().chain(r.features.iter()))
.filter(|s| !s.is_empty())
.map(|s| s.len() + std::mem::size_of::<String>())
.sum();
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
assert_eq!(t.runs[0].family.len(), 16 * 1024);
assert_eq!(t.runs[0].features.len(), 256);
assert!(t.runs.last().unwrap().family.is_empty());
assert!(t.runs.last().unwrap().features.is_empty());
assert!(warning(&d, "text attribute budget exceeded"));
}
#[test]
fn shared_gradient_attributes_are_bounded_without_losing_characters() {
let color =
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
let mut colors = vec![color];
colors.extend((1..1024).map(|_| F::Ref(43)));
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
let gradient = F::Obj(
tag(b"FilG"),
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
);
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![gradient]))]);
let mut runs = vec![run(1, Some(attrs))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
let stop_count: usize = t
.runs
.iter()
.map(|r| match &r.fill {
Paint::Gradient(g) => g.stops.len(),
_ => 0,
})
.sum();
assert!(stop_count > 0);
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
let Paint::Gradient(first) = &t.runs[0].fill else { panic!("first gradient must fit") };
assert_eq!(first.stops.len(), 1024);
assert!(matches!(t.runs.last().unwrap().fill, Paint::Solid(_)));
assert!(warning(&d, "text attribute budget exceeded"));
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
}
+87 -30
View File
@@ -4,13 +4,21 @@
//! in the directions normal to the path. For nib axes `u`, `v` with semi-axes `a`, `b` the
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
//!
//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
//! sample takes the nib for the pressure there.
use vectorcraft_color::Paint;
use vectorcraft_doc::Node;
use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{Bristle, BristleShape, Calligraphic, filled, tolerance};
use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
/// it takes along a whole subpath.
const MAX_STEPS: f64 = 1024.0;
/// An elliptical nib.
#[derive(Clone, Copy, Debug)]
@@ -43,39 +51,53 @@ fn rot(base: Vec2, angle: f64) -> Vec2 {
base * angle.cos() + normal(base) * angle.sin()
}
/// Samples (point, tangent) along a track, with extra tangents fanned in at corners so the
/// outer edge of the sweep follows the nib around the corner.
fn samples(t: &Track) -> Vec<(Point, Vec2)> {
/// Samples (point, tangent, fraction of the track's length) along a track, with extra tangents
/// fanned in at corners so the outer edge of the sweep follows the nib around the corner. `step`
/// (for a nib that varies along the track): segments are cut into pieces at most that long.
fn samples(t: &Track, step: Option<f64>) -> Vec<(Point, Vec2, f64)> {
let mut out = vec![];
let n = t.segments();
let len = t.len().max(1e-12);
for i in 0..=n {
let p = t.verts[i];
let f = t.cum[i] / len;
if t.corner[i] {
let (a, b) = t.in_out(i);
let turn = a.cross(b).atan2(a.dot(b));
let steps = (turn.abs() / 10f64.to_radians()).ceil().max(1.0) as usize;
for k in 0..=steps {
out.push((p, rot(a, turn * k as f64 / steps as f64)));
out.push((p, rot(a, turn * k as f64 / steps as f64), f));
}
} else {
out.push((p, t.tangents[i]));
out.push((p, t.tangents[i], f));
}
if let Some(step) = step
&& i < n
{
let seg = t.cum[i + 1] - t.cum[i];
let pieces = (seg / step).ceil().clamp(1.0, MAX_STEPS) as usize;
for k in 1..pieces {
let u = k as f64 / pieces as f64;
out.push((p.lerp(t.verts[i + 1], u), t.dirs[i], (t.cum[i] + seg * u) / len));
}
}
}
out
}
/// The swept outline of `nib` along `t` as closed subpaths.
pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
let s = samples(t);
let left: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(-normal(*tg))).collect();
/// The swept outline along `t` of the nib `nib(f)` (`f`: the fraction of the track's length) as
/// closed subpaths. `step`: how often a varying nib is sampled (None: a constant nib).
pub(crate) fn sweep(t: &Track, nib: &dyn Fn(f64) -> Nib, step: Option<f64>) -> Vec<SubPath> {
let s = samples(t, step);
let left: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(-normal(*tg))).collect();
if t.closed {
let mut r = right;
r.reverse();
return vec![SubPath::polyline(&left, true), SubPath::polyline(&r, true)];
}
let cap = |p: Point, tg: Vec2, from: f64, to: f64, out: &mut Vec<Point>| {
let steps = 16;
let cap = |(p, tg, f): (Point, Vec2, f64), from: f64, to: f64, out: &mut Vec<Point>| {
let (steps, nib) = (16, nib(f));
for k in 1..steps {
let ang = from + (to - from) * k as f64 / steps as f64;
out.push(p + nib.support(rot(tg, ang)));
@@ -83,31 +105,66 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
};
let half = std::f64::consts::FRAC_PI_2;
let mut poly = left.clone();
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
cap(pe, te, half, -half, &mut poly);
let (Some(&end), Some(&start)) = (s.last(), s.first()) else { return Vec::new() };
cap(end, half, -half, &mut poly);
poly.extend(right.iter().rev());
cap(ps, ts, -half, -3.0 * half, &mut poly);
cap(start, -half, -3.0 * half, &mut poly);
vec![SubPath::polyline(&poly, true)]
}
fn randomized(c: &Calligraphic, rng: &mut Rng) -> (f64, f64, f64) {
let [va, vr, vs] = c.variation;
let angle = c.angle + rng.range(-va, va);
let round = (c.roundness + rng.range(-vr, vr)).clamp(1.0, 100.0);
let size = (c.size + rng.range(-vs, vs)).max(0.05);
(angle, round, size)
/// The stroke's angle, roundness and size: Random ones drawn for this stroke (from `rng`), the
/// others the brush's values.
fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
let mut out = [c.angle, c.roundness, c.size];
for ((v, var), mode) in out.iter_mut().zip(c.variation).zip(c.modes()) {
// Every value draws, so a stroke's random values stay the same whatever the other modes.
let r = rng.range(-var, var);
if mode == Variation::Random {
*v += r;
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, paint: &Paint, name: &str) -> Vec<Node> {
/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
/// kept within its range; the others are `base`'s.
fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
let values = [c.angle, c.roundness, c.size];
let mut out = base;
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode == Variation::Pressure {
let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
*v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
let mut rng = Rng::new(seed_of(bp, name));
let (angle, round, size) = randomized(c, &mut rng);
let size = size * weight;
let nib = Nib::new(angle, round / 100.0, size);
let subs: Vec<SubPath> = tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib)).collect();
let base = randomized(c, &mut rng);
let subs: Vec<SubPath> = if c.uses_pressure() {
let pressure = stroke.pressure.as_ref();
let nib = |f: f64| {
let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
Nib::new(angle, round / 100.0, size * weight)
};
// Flattened for the largest nib the pressure gives.
let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
} else {
let [angle, round, size] = base;
let nib = Nib::new(angle, round / 100.0, size * weight);
tracks(bp, tolerance(size * weight)).iter().flat_map(|t| sweep(t, &|_| nib, None)).collect()
};
if subs.is_empty() {
return vec![];
}
vec![filled(PathData::new(subs), paint)]
vec![filled(PathData::new(subs), &stroke.paint)]
}
/// Offset a track sideways by `d` (a polyline following it).
@@ -165,7 +222,7 @@ pub(crate) fn bristle(b: &Bristle, bp: &BezPath, weight: f64, paint: &Paint, nam
let Some(sp) = offset_track(&t, off, t0.min(l * 0.3), t1.min(l * 0.3)) else { continue };
let w = thick * (0.6 + 0.4 * edge) * rng.range(0.8, 1.2);
let nib = Nib::new(0.0, 1.0, w);
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &nib)).collect();
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &|_| nib, None)).collect();
if subs.is_empty() {
continue;
}
+28
View File
@@ -115,3 +115,31 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
}
a
}
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
/// its default.
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
for it in &n.appearance.items {
let (visible, paint) = match it {
AppearanceItem::Fill(f) => (f.visible, &f.paint),
AppearanceItem::Stroke(s) => (s.visible, &s.paint),
};
if let (true, Some(c)) = (visible, paint.color()) {
let order = seen.len();
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
for c in n.children().into_iter().flatten() {
walk(c, seen);
}
}
let mut seen = std::collections::HashMap::new();
for n in nodes {
walk(n, &mut seen);
}
let mut v: Vec<(usize, usize, Color)> = seen.into_values().collect();
v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
v.into_iter().map(|(.., c)| c).collect()
}
+5 -3
View File
@@ -26,7 +26,7 @@ fn group(children: Vec<Node>) -> Node {
fn compound(paths: Vec<PathData>, c: Color) -> Node {
let children = paths.into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
n.appearance = fill(c);
n.appearance = fill(c).into();
n
}
@@ -121,7 +121,7 @@ pub(crate) fn stitch_tile() -> Node {
let dash = path(shapes::rounded_rectangle(Rect::new(1.5, -0.75, 8.5, 0.75), 0.75), Color::BLACK);
// Invisible spacer that fixes the tile width.
let mut spacer = path(shapes::rectangle(Rect::new(0.0, -0.75, 10.0, 0.75)), Color::BLACK);
spacer.appearance = Appearance::default();
spacer.appearance = Appearance::default().into();
group(vec![spacer, dash])
}
@@ -132,7 +132,7 @@ fn diamond_tile() -> Node {
}
fn calli(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
/// The default library.
@@ -172,6 +172,7 @@ pub fn defaults() -> &'static [Brush] {
rotation: (-180.0, 180.0),
rotation_relative_to_path: false,
colorization: Colorization::Tints,
modes: None,
}),
},
Brush {
@@ -184,6 +185,7 @@ pub fn defaults() -> &'static [Brush] {
rotation: (-35.0, 35.0),
rotation_relative_to_path: true,
colorization: Colorization::None,
modes: None,
}),
},
Brush {
+226 -13
View File
@@ -23,7 +23,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Rect};
pub use colorize::{colorize, instance_art, stain, tint_node};
pub use colorize::{art_colors, colorize, instance_art, stain, tint_node};
pub use defaults::defaults;
pub use serde_json;
@@ -71,6 +71,15 @@ impl BrushKind {
BrushKind::Bristle(_) => "bristle",
}
}
/// How the brush's art takes the stroke colour (Calligraphic and Bristle brushes paint with it).
pub fn colorization(&self) -> Colorization {
match self {
BrushKind::Art(a) => a.colorization,
BrushKind::Scatter(s) => s.colorization,
BrushKind::Pattern(p) => p.colorization,
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => Colorization::None,
}
}
}
/// How brush art takes the stroke colour.
@@ -88,6 +97,29 @@ pub enum Colorization {
HueShift { key: vectorcraft_color::Color },
}
impl Colorization {
pub const IDS: [&'static str; 4] = ["none", "tints", "tintsAndShades", "hueShift"];
/// The name brush definitions use (`method`).
pub fn id(&self) -> &'static str {
match self {
Colorization::None => "none",
Colorization::Tints => "tints",
Colorization::TintsAndShades => "tintsAndShades",
Colorization::HueShift { .. } => "hueShift",
}
}
/// The method named `id` (`key`: the key colour of Hue Shift).
pub fn parse(id: &str, key: vectorcraft_color::Color) -> Option<Self> {
Some(match id {
"none" => Colorization::None,
"tints" => Colorization::Tints,
"tintsAndShades" => Colorization::TintsAndShades,
"hueShift" => Colorization::HueShift { key },
_ => return None,
})
}
}
/// Calligraphic brush: an elliptical nib swept along the path. Sizes are at 1 pt stroke weight
/// (the stroke weight scales the brush).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -99,13 +131,69 @@ pub struct Calligraphic {
pub roundness: f64,
/// Nib diameter in points.
pub size: f64,
/// Random variation per stroke: angle (°), roundness (%), size (pt).
/// How far angle (°), roundness (%) and size (pt) vary each way, as [`Self::modes`] say.
pub variation: [f64; 3],
/// How angle, roundness and size vary. `None` (brushes saved before there were modes): Random
/// where the variation is non-zero, else Fixed — see [`Calligraphic::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 3]>,
}
impl Default for Calligraphic {
fn default() -> Self {
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3] }
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3], modes: None }
}
}
impl Calligraphic {
/// The ranges of angle (°), roundness (%) and size (pt), and the most each varies.
pub const RANGES: [(f64, f64); 3] = [(-180.0, 180.0), (0.0, 100.0), (0.0, 1296.0)];
pub const MAX_VARIATION: [f64; 3] = [180.0, 100.0, 1296.0];
/// How angle, roundness and size vary.
pub fn modes(&self) -> [Variation; 3] {
self.modes.unwrap_or_else(|| self.variation.map(|v| if v != 0.0 { Variation::Random } else { Variation::Fixed }))
}
/// Does the pen's pressure change the nib?
pub fn uses_pressure(&self) -> bool {
self.modes().contains(&Variation::Pressure)
}
}
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
/// modes are appended, never renamed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Variation {
/// Always the value.
#[default]
Fixed,
/// A random value within the variation either side, new for each stroke.
Random,
/// From the value less the variation at the lightest pen pressure to the value plus the
/// variation at the heaviest.
Pressure,
}
impl Variation {
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
/// The name brush definitions use.
pub fn id(self) -> &'static str {
match self {
Variation::Fixed => "fixed",
Variation::Random => "random",
Variation::Pressure => "pressure",
}
}
pub fn label(self) -> &'static str {
match self {
Variation::Fixed => "Fixed",
Variation::Random => "Random",
Variation::Pressure => "Pressure",
}
}
pub fn parse(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == id)
}
}
@@ -125,6 +213,12 @@ pub struct Scatter {
/// Rotation relative to the path direction (else to the page).
pub rotation_relative_to_path: bool,
pub colorization: Colorization,
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
/// the two differ, else Fixed — see [`Scatter::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 4]>,
}
impl Default for Scatter {
@@ -137,10 +231,25 @@ impl Default for Scatter {
rotation: (0.0, 0.0),
rotation_relative_to_path: false,
colorization: Colorization::None,
modes: None,
}
}
}
impl Scatter {
/// The ranges of size (%), spacing (%), scatter (%) and rotation (°).
pub const RANGES: [(f64, f64); 4] = [(1.0, 10000.0), (1.0, 10000.0), (-1000.0, 1000.0), (-180.0, 180.0)];
/// Size, spacing, scatter and rotation, each as its (first, second) values.
pub fn values(&self) -> [(f64, f64); 4] {
[self.size, self.spacing, self.scatter, self.rotation]
}
/// How size, spacing, scatter and rotation vary.
pub fn modes(&self) -> [Variation; 4] {
self.modes.unwrap_or_else(|| self.values().map(|(a, b)| if a != b { Variation::Random } else { Variation::Fixed }))
}
}
/// Direction the art runs in the brush definition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -180,6 +289,11 @@ pub struct ArtBrush {
pub colorization: Colorization,
}
impl ArtBrush {
/// The range of the width (%).
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
}
impl Default for ArtBrush {
fn default() -> Self {
Self {
@@ -230,6 +344,12 @@ pub struct PatternBrush {
pub colorization: Colorization,
}
impl PatternBrush {
/// The ranges of the scale and of the spacing (%).
pub const SCALE_RANGE: (f64, f64) = (1.0, 10000.0);
pub const SPACING_RANGE: (f64, f64) = (0.0, 10000.0);
}
impl Default for PatternBrush {
fn default() -> Self {
Self {
@@ -284,12 +404,104 @@ pub struct Bristle {
pub stiffness: f64,
}
impl Bristle {
/// The ranges of the size (pt), the bristle length (%) and the other percentages.
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
}
impl Default for Bristle {
fn default() -> Self {
Self { shape: BristleShape::RoundPoint, size: 6.0, length: 100.0, density: 50.0, thickness: 40.0, opacity: 75.0, stiffness: 50.0 }
}
}
// ---------- validation ----------
/// `v` within `(lo, hi)`; `fallback` (kept within too) when it isn't a number.
fn within(v: f64, (lo, hi): (f64, f64), fallback: f64) -> f64 {
if v.is_finite() { v.clamp(lo, hi) } else { fallback.clamp(lo, hi) }
}
fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f64) {
(within(a, range, fallback), within(b, range, fallback))
}
impl Colorization {
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
fn sanitize(&mut self) {
if let Colorization::HueShift { key } = self
&& !key.to_rgb().iter().all(|c| c.is_finite())
{
*key = vectorcraft_color::Color::BLACK;
}
}
}
impl Brush {
/// The art the brush is made of: a scatter or art brush's art, a pattern brush's tiles.
pub fn art(&self) -> Vec<&Node> {
match &self.kind {
BrushKind::Scatter(s) => vec![&s.art],
BrushKind::Art(a) => vec![&a.art],
BrushKind::Pattern(p) => {
std::iter::once(&p.side).chain([&p.outer_corner, &p.inner_corner, &p.start, &p.end].into_iter().flatten()).collect()
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
}
}
/// Keep every value of the definition within its range (definitions come from files and
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
pub fn sanitize(&mut self) {
match &mut self.kind {
BrushKind::Calligraphic(c) => {
let d = Calligraphic::default();
let [angle, roundness, size] = Calligraphic::RANGES;
c.angle = within(c.angle, angle, d.angle);
c.roundness = within(c.roundness, roundness, d.roundness);
c.size = within(c.size, size, d.size);
for (v, max) in c.variation.iter_mut().zip(Calligraphic::MAX_VARIATION) {
*v = within(*v, (0.0, max), 0.0);
}
}
BrushKind::Scatter(s) => {
let d = Scatter::default();
let [size, spacing, scatter, rotation] = Scatter::RANGES;
s.size = within_pair(s.size, size, d.size.0);
s.spacing = within_pair(s.spacing, spacing, d.spacing.0);
s.scatter = within_pair(s.scatter, scatter, 0.0);
s.rotation = within_pair(s.rotation, rotation, 0.0);
s.colorization.sanitize();
}
BrushKind::Art(a) => {
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
if let ArtScale::BetweenGuides { start, end } = &mut a.scale {
*start = within(*start, (0.0, 1.0), 0.0);
*end = within(*end, (0.0, 1.0), 1.0);
}
a.colorization.sanitize();
}
BrushKind::Pattern(p) => {
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
p.colorization.sanitize();
}
BrushKind::Bristle(b) => {
let d = Bristle::default();
b.size = within(b.size, Bristle::SIZE_RANGE, d.size);
b.length = within(b.length, Bristle::LENGTH_RANGE, d.length);
for (v, fallback) in
[(&mut b.density, d.density), (&mut b.thickness, d.thickness), (&mut b.opacity, d.opacity), (&mut b.stiffness, d.stiffness)]
{
*v = within(*v, Bristle::PERCENT_RANGE, fallback);
}
}
}
}
}
// ---------- library ----------
/// The brush library of `doc`: its stored brushes, or the defaults when it has none.
@@ -300,9 +512,15 @@ pub fn library(doc: &Document) -> Vec<Brush> {
}
}
/// Parse a stored library value (malformed entries are skipped).
/// Parse a stored library value (malformed entries are skipped, values kept within their
/// ranges — see [`Brush::sanitize`]).
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
v.as_array().map(|a| a.iter().filter_map(|b| serde_json::from_value(b.clone()).ok()).collect()).unwrap_or_default()
let parse = |b: &serde_json::Value| {
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
b.sanitize();
Some(b)
};
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
}
/// Store `lib` as the document's brush library.
@@ -345,18 +563,13 @@ fn tolerance(scale: f64) -> f64 {
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, &stroke.paint, &brush.name),
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, &brush.name),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
let colorization = match &brush.kind {
BrushKind::Art(a) => a.colorization,
BrushKind::Scatter(s) => s.colorization,
BrushKind::Pattern(p) => p.colorization,
_ => Colorization::None,
};
let colorization = brush.kind.colorization();
for n in &mut out {
colorize(n, colorization, &stroke.paint);
}
+189 -2
View File
@@ -16,7 +16,7 @@ fn stroke(w: f64) -> StrokeLayer {
}
fn calli(angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
fn bounds(nodes: &[Node]) -> Rect {
@@ -118,7 +118,7 @@ fn art_brush_arc_length_mapping_is_linear() {
Node::path(NodeId(0), shapes::rectangle(Rect::new(5.0, -1.0, 6.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let frame = rect_art(10.0, 2.0);
let mut spacer = frame.clone();
spacer.appearance = Appearance::default();
spacer.appearance = Appearance::default().into();
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let g = &out[0];
@@ -334,3 +334,190 @@ fn expand_keeps_fill_and_adds_pieces() {
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art());
assert!(expand(&d, &plain).is_none());
}
/// A Calligraphic brush (round nib) whose size follows the pen pressure by `var` either way.
fn pressure_calli(size: f64, var: f64) -> Brush {
let c = Calligraphic {
size,
variation: [0.0, 0.0, var],
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
..Default::default()
};
Brush { name: "p".into(), kind: BrushKind::Calligraphic(c) }
}
/// The stroke's half-width (largest |y|) among outline points with x in `x0..x1`.
fn half_width(nodes: &[Node], x0: f64, x1: f64) -> f64 {
let mut pts = vec![];
for n in nodes {
all_points(n, &mut pts);
}
pts.iter().filter(|p| p.x >= x0 && p.x <= x1).map(|p| p.y.abs()).fold(0.0, f64::max)
}
fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) }
}
/// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to
/// size + variation at the heaviest, along the stroke where the pen pressed.
#[test]
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
/// Angle and roundness follow the pressure the same way, clamped to their ranges.
#[test]
fn pressure_varies_angle_and_roundness() {
let c = |modes| Calligraphic { angle: 0.0, roundness: 60.0, size: 10.0, variation: [90.0, 60.0, 0.0], modes: Some(modes) };
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
assert!(near(draw(c([Pressure, Fixed, Fixed]), 0.5), 6.0, 0.05));
assert!(near(draw(c([Pressure, Fixed, Fixed]), 1.0), 10.0, 0.05));
// Roundness 60 ± 60, kept within 1..100: light is a hairline, heavy is round.
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
}
/// Brushes saved before the variation modes keep their look: a variation means Random, none
/// means Fixed, and they are written back without modes.
#[test]
fn brushes_without_modes_vary_randomly_as_before() {
let old: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "size": 8, "variation": [0, 0, 3]})).unwrap();
let BrushKind::Calligraphic(c) = &old.kind else { panic!() };
assert_eq!(c.modes, None);
assert_eq!(c.modes(), [Variation::Fixed, Variation::Fixed, Variation::Random]);
assert!(!c.uses_pressure());
assert!(serde_json::to_value(&old).unwrap().get("modes").is_none(), "written back as it was");
// The same strokes as the brush with its modes spelled out.
let explicit = Brush {
kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed, Variation::Fixed, Variation::Random]), ..c.clone() }),
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
}
/// Junk definitions and pressure never panic and never draw non-finite art.
#[test]
fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
}
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{size} {var}");
}
}
}
/// A scatter brush of 10 × 4 pt rectangles whose size goes from `lo` % to `hi` % as `mode` says.
fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
let s = scatter_brush(100.0);
let BrushKind::Scatter(sc) = s.kind else { panic!("not a scatter brush") };
let modes = [mode, Variation::Fixed, Variation::Fixed, Variation::Fixed];
Brush { kind: BrushKind::Scatter(Scatter { size: (lo, hi), modes: Some(modes), ..sc }), ..s }
}
/// Scatter Brush Options: Fixed takes the first value, Pressure goes from the first at the
/// lightest pen pressure to the second at the heaviest; brushes saved before modes keep their look.
#[test]
fn scatter_values_follow_their_modes() {
let bp = line(0.0, 0.0, 400.0, 0.0);
let heights =
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
// Fixed: every copy at 50 %, whatever the second value.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let h = heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &ramp);
let (first, last) = (h[0], h[h.len() - 1]);
assert!(near(first, 2.0, 0.1) && last > 10.0, "{h:?}");
assert!(h.windows(2).all(|w| w[1] >= w[0] - 1e-9), "grows with the pressure: {h:?}");
// Without recorded pressure, half way between the two.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &stroke(1.0)).iter().all(|h| near(*h, 7.0, 1e-6)));
// No modes saved: Random where the values differ, as before; Fixed and Random draw the same.
let old = sized_scatter(50.0, 300.0, Variation::Random);
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
/// Definitions from files and commands are kept within their ranges.
#[test]
fn sanitize_keeps_values_within_their_ranges() {
let mut v = serde_json::to_value(sized_scatter(1e308, -1e308, Variation::Random)).unwrap();
v["rotation"] = serde_json::json!([720.0, -1e300]);
v["scatter"] = serde_json::json!([5000.0, 0.0]);
let lib = parse_library(&serde_json::json!([
v,
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width": 1e308, "scale": {"mode": "betweenGuides", "start": -3.0, "end": 9.0}},
{"name": "p", "type": "pattern", "side": rect_art(10.0, 4.0), "scale": 0.0, "spacing": -5.0},
{"name": "c", "type": "calligraphic", "angle": 400.0, "roundness": -1.0, "size": 1e9, "variation": [1e9, -1.0, 2.0]},
{"name": "b", "type": "bristle", "size": 0.0, "length": 1000.0, "density": 0.0},
]));
let [s, a, p, c, b] = lib.as_slice() else { panic!("{lib:?}") };
let BrushKind::Scatter(s) = &s.kind else { panic!() };
assert_eq!((s.size, s.rotation, s.scatter), ((10000.0, 1.0), (180.0, -180.0), (1000.0, 0.0)));
let BrushKind::Art(a) = &a.kind else { panic!() };
assert_eq!((a.width, a.scale), (1000.0, ArtScale::BetweenGuides { start: 0.0, end: 1.0 }));
let BrushKind::Pattern(p) = &p.kind else { panic!() };
assert_eq!((p.scale, p.spacing), (1.0, 0.0));
let BrushKind::Calligraphic(c) = &c.kind else { panic!() };
assert_eq!((c.angle, c.roundness, c.size, c.variation), (180.0, 0.0, 1296.0, [180.0, 0.0, 2.0]));
let BrushKind::Bristle(b) = &b.kind else { panic!() };
assert_eq!((b.size, b.length, b.density), (0.1, 300.0, 1.0));
}
/// Hue Shift's key colour defaults to the art's most used colour.
#[test]
fn art_colors_list_the_most_used_first() {
let red = Color::rgb(1.0, 0.0, 0.0);
let blue = Color::rgb(0.0, 0.0, 1.0);
let path =
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
+25 -7
View File
@@ -6,11 +6,11 @@
use std::sync::Arc;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
use crate::track::{Rng, Track, seed_of, tracks};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, tolerance};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
/// Longest mapped segment (points in document space).
const MAX_SEG: f64 = 1.5;
@@ -188,13 +188,28 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
out
}
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Vec<Node> {
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
/// draws, so a copy's random values stay the same whatever the other modes.
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
let r = rng.range(a, b);
match mode {
Variation::Fixed => a,
Variation::Random => r,
Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
}
}
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
let mut rng = Rng::new(seed_of(bp, name));
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
let mut out = vec![];
for t in tracks(bp, 0.05) {
let l = t.len();
// The pen pressure at arc length `at` (half way without a recorded one).
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
let mut s: Option<f64> = None;
let mut guard = 0;
loop {
@@ -202,8 +217,10 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
if guard > 20000 {
break;
}
let size = rng.range(sc.size.0, sc.size.1).max(1.0) / 100.0 * weight;
let spacing = rng.range(sc.spacing.0, sc.spacing.1).max(1.0) / 100.0;
// Size and spacing place the next copy, so they take the pressure at the last one.
let p = pressure_at(s.unwrap_or(0.0));
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
let step = aw * size * spacing;
let pos = match s {
None => step / 2.0,
@@ -213,8 +230,9 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
break;
}
s = Some(pos);
let off = rng.range(sc.scatter.0, sc.scatter.1) / 100.0 * ah * size;
let rot = rng.range(sc.rotation.0, sc.rotation.1).to_radians();
let p = pressure_at(pos);
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
let (p, tg) = t.frame(pos);
let place = p + crate::track::normal(tg) * off;
// Page rotation is counter-clockwise on screen (y-down), like the calligraphic angle.
+4 -1
View File
@@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) {
/// The name of `$INSUNITS` code `code`.
fn units_name(code: i64) -> &'static str {
const NAMES: [&str; 22] = [
const NAMES: [&str; 25] = [
"Unitless",
"Inches",
"Feet",
@@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str {
"Light years",
"Parsecs",
"US survey feet",
"US survey inches",
"US survey yards",
"US survey miles",
];
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
}
+8 -3
View File
@@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> {
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
let m = 72.0 / 0.0254;
// The US survey foot: exactly 1200/3937 m.
let survey_ft = m * 1200.0 / 3937.0;
Some(match code {
1 => 72.0,
2 => 864.0,
@@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option<f64> {
18 => m * 1.495_978_707e11,
19 => m * 9.460_730_472_580_8e15,
20 => m * 3.085_677_581e16,
21 => m * 1200.0 / 3937.0,
21 => survey_ft,
22 => survey_ft / 12.0,
23 => survey_ft * 3.0,
24 => survey_ft * 5280.0,
_ => return None,
})
}
@@ -474,7 +479,7 @@ impl<'d, 'a> Build<'d, 'a> {
.into_iter()
.map(|sp| {
let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp)));
c.appearance = appearance.clone();
c.appearance = appearance.clone().into();
Arc::new(c)
})
.collect();
@@ -482,7 +487,7 @@ impl<'d, 'a> Build<'d, 'a> {
} else {
Node::new(self.doc.alloc_id(), kind(path))
};
n.appearance = appearance;
n.appearance = appearance.into();
Some(n)
}
+8 -1
View File
@@ -473,7 +473,14 @@ fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
curve::nurbs(degree, &knots, &ctrl, weights)
.map(|mut path| {
if flags & 1 != 0 {
path.close_path();
}
path
})
.or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
}
/// Reads a hatch's boundary groups in order.
+61
View File
@@ -128,6 +128,20 @@ fn units_choose_the_default_scale() {
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
assert!((um.scale - 1000.0).abs() < 1e-6);
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
for (code, name, pt) in [
("21", "US survey feet", survey_ft),
("22", "US survey inches", survey_ft / 12.0),
("23", "US survey yards", survey_ft * 3.0),
("24", "US survey miles", survey_ft * 5280.0),
] {
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
}
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
@@ -256,6 +270,53 @@ fn nurbs_splines_stay_within_a_hundredth() {
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
}
#[test]
fn control_point_splines_honor_closed_flags_without_changing_their_curves() {
for returns in [false, true] {
for rational in [false, true] {
let mut original_curves = None;
for closed in [false, true] {
let flags = 8 | if rational { 4 } else { 0 } | i32::from(closed);
let mut e = vec![(0, "SPLINE".to_string()), (70, flags.to_string()), (71, "3".into()), (72, "11".into()), (73, "7".into())];
for k in [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2] {
e.push((40, k.to_string()));
}
let end = if returns { (20.0, 20.0) } else { (50.0, 25.0) };
for ((x, y), weight) in [(20.0, 20.0), (80.0, 0.0), (80.0, 80.0), (20.0, 80.0), (0.0, 80.0), (0.0, 20.0), end]
.into_iter()
.zip([1.0, 0.7, 0.7, 1.0, 0.9, 0.9, 1.0])
{
e.extend([(10, x.to_string()), (20, y.to_string()), (30, "0".into())]);
if rational {
e.push((41, weight.to_string()));
}
}
let e: Vec<_> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
let doc = open(&entities(&e), &points()).document;
let [n] = &leaves(&doc)[..] else { panic!("one spline") };
let path = n.path_data().unwrap();
assert_eq!(path.subpaths.len(), 1);
assert_eq!(path.subpaths[0].closed, closed, "flags {flags}, returns {returns}");
let curves: Vec<_> = path.to_bezpath().elements().iter().filter(|e| matches!(e, kurbo::PathEl::CurveTo(..))).copied().collect();
assert!(!curves.is_empty());
if let Some(original) = &original_curves {
assert_eq!(&curves, original, "closure preserves the rational/non-rational curves");
} else {
original_curves = Some(curves);
}
}
}
}
for flags in [8, 9] {
let flags_text = flags.to_string();
let e =
[(0, "SPLINE"), (70, flags_text.as_str()), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
let doc = open(&entities(&e), &points()).document;
let [n] = &leaves(&doc)[..] else { panic!("one fit-point spline") };
assert_eq!(n.path_data().unwrap().subpaths[0].closed, flags & 1 != 0, "fit-point fallback stays closed/open");
}
}
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
vec![
+3 -3
View File
@@ -2,9 +2,9 @@
//!
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
//! become polylines (straight segments) or cubic splines (curves), fills solid hatches, type text
//! entities or glyph outlines, and placed images image entities linked to PNG or JPEG files the
//! caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per
//! become polylines (straight segments) or cubic splines (curves), fills their outlines and solid
//! hatches, type text entities or glyph outlines, and placed images image entities linked to PNG
//! or JPEG files the caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per
//! `unit` of the art, with the origin at the bottom-left corner of [`DxfOptions::region`].
//!
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
+15 -9
View File
@@ -1,6 +1,7 @@
//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint
//! order. Fills are solid hatches (outlines in R12), strokes polylines and cubic splines with a
//! lineweight and dashes, or filled outlines where the look needs them.
//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes
//! polylines and cubic splines with a lineweight and dashes, or filled outlines where the look
//! needs them.
use std::collections::HashMap;
use std::io::Cursor;
@@ -315,7 +316,7 @@ impl<'a> Scene<'a> {
}
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
@@ -348,7 +349,7 @@ impl<'a> Scene<'a> {
if n.blend != vectorcraft_color::BlendMode::Normal {
self.warn("blending modes are left out");
}
if n.mask.as_ref().is_some_and(|m| !m.disabled) {
if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) {
self.warn("opacity masks are left out");
}
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id));
@@ -504,8 +505,9 @@ impl<'a> Scene<'a> {
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
}
/// A filled area as a solid hatch (R12: its outlines). `clean`: the path has no overlaps, so
/// the hatch's even-odd rule reads it as it is.
/// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser
/// and cutter software reads the outlines and skips hatches. `clean`: the path has no
/// overlaps, so the hatch's even-odd rule reads it as it is.
fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) {
if !self.o.version.handles() {
self.warn(NO_FILLS);
@@ -517,10 +519,12 @@ impl<'a> Scene<'a> {
.then(|| vectorcraft_pathops::try_normalize(&PathData::from_bezpath(bp), rule).ok())
.flatten()
.map(|p| p.to_bezpath());
let loops: Vec<Vec<Point>> = polygons(normalized.as_ref().unwrap_or(bp)).into_iter().filter(|p| p.len() >= 3).collect();
let outline = normalized.as_ref().unwrap_or(bp);
let loops: Vec<Vec<Point>> = polygons(outline).into_iter().filter(|p| p.len() >= 3).collect();
if loops.is_empty() {
return;
}
self.lines(outline, style, 0.0);
let h = self.handle();
let layer = self.layer.clone();
self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch");
@@ -648,8 +652,10 @@ impl<'a> Scene<'a> {
let h = self.handle();
self.e.entity("SPLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbSpline");
self.e.normal();
// Planar.
self.e.int(70, 8);
// Planar (bit 3) and, if the subpath is closed, also closed (bit 0). A geometrically
// closed cut needs the closed flag set so CAM/CAM tools (e.g. VCarve) treat it as a
// pocket/profile rather than asking the operator to join endpoints. (#1299)
self.e.int(70, if sub.closed { 8 | 1 } else { 8 });
self.e.int(71, 3);
self.e.int(72, knots.len() as i64);
self.e.int(73, ctrl.len() as i64);
+100 -27
View File
@@ -129,30 +129,43 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let d = shapes_doc();
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), ["HATCH", "LWPOLYLINE", "SPLINE"], "fill, then stroke, then the ellipse's stroke");
assert_eq!(
kinds(&out),
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
);
let e = entities(&out);
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
let poly = &e[1];
assert_eq!(get(poly, 70), Some("1"), "closed");
let mut xs = all(poly, 10);
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [10.0, 10.0, 110.0, 110.0]);
let mut ys = all(poly, 20);
ys.sort_by(f64::total_cmp);
assert_eq!(ys, [40.0, 40.0, 90.0, 90.0]);
let hatch = &e[0];
let corners = |poly: &Pairs| {
let mut xs = all(poly, 10);
xs.sort_by(f64::total_cmp);
let mut ys = all(poly, 20);
ys.sort_by(f64::total_cmp);
(xs, ys)
};
let rect = (vec![10.0, 10.0, 110.0, 110.0], vec![40.0, 40.0, 90.0, 90.0]);
for poly in [&e[0], &e[2]] {
assert_eq!(get(poly, 70), Some("1"), "closed");
assert_eq!(corners(poly), rect);
}
// Laser and cutter software skips hatches: the fill's outline comes first, without a lineweight.
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
let hatch = &e[1];
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
// The ellipse: four cubics through clamped knots.
let spline = &e[2];
let spline = &e[3];
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&r14)[1], 43), Some("1"));
assert_eq!(get(&entities(&r14)[1], 370), None);
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
assert_eq!(get(&entities(&r14)[2], 370), None);
assert_eq!(get(&entities(&r14)[0], 43), None, "a fill's outline has no width");
// R13 has splines and hatches, and polylines of vertices.
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
assert_eq!(kinds(&r13), ["HATCH", "POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND", "SPLINE"]);
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
// R12: polylines only; the fill is its outline, the ellipse flattened.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let k = kinds(&r12);
@@ -162,6 +175,60 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
}
#[test]
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
let cases = [(true, true), (true, false), (false, false), (false, true)];
let make_doc = |keep_closure: bool| {
doc_with(|d| {
cases
.iter()
.map(|&(closed, returns)| {
let start = Point::new(20.0, 20.0);
let mut b = kurbo::BezPath::new();
b.move_to(start);
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
if returns {
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
}
if closed {
if keep_closure {
b.close_path();
} else if !returns {
// The open reference has the same final straight segment.
b.line_to(start);
}
}
path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 0.0)), 2.0)
})
.collect()
})
};
let closed = make_doc(true);
let open = make_doc(false);
for version in DxfVersion::ALL {
let out = export(&closed, &DxfOptions { version, ..opts(&closed) }).unwrap();
let actual = entities(&out);
if version == DxfVersion::R12 {
let polys: Vec<_> = actual.iter().filter(|e| kind(e) == "POLYLINE").collect();
assert_eq!(polys.len(), cases.len());
for (poly, &(closed, _)) in polys.iter().zip(&cases) {
assert_eq!(get(poly, 70), Some(if closed { "1" } else { "0" }));
}
assert!(!actual.iter().any(|e| kind(e) == "SPLINE"));
continue;
}
let reference = entities(&export(&open, &DxfOptions { version, ..opts(&open) }).unwrap());
assert_eq!(actual.len(), cases.len());
for ((spline, reference), &(closed, _)) in actual.iter().zip(&reference).zip(&cases) {
assert_eq!(kind(spline), "SPLINE");
assert_eq!(get(spline, 70), Some(if closed { "9" } else { "8" }), "{version:?}");
let mut normalized = spline.clone();
normalized.iter_mut().filter(|p| p.0 == 70).for_each(|p| p.1 = "8".into());
assert_eq!(&normalized, reference, "all other entity properties, knots and controls: {version:?}");
}
}
}
#[test]
fn colours_map_to_the_index_palette_or_true_colour() {
let d = shapes_doc();
@@ -171,8 +238,9 @@ fn colours_map_to_the_index_palette_or_true_colour() {
};
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000");
assert_eq!(style(&out, 1), (Some("7".into()), Some("0".into())), "black is index 7");
assert_eq!(style(&out, 2), (Some("5".into()), Some("255".into())));
assert_eq!(style(&out, 1), style(&out, 0), "the fill's outline has the fill's colour");
assert_eq!(style(&out, 2), (Some("7".into()), Some("0".into())), "black is index 7");
assert_eq!(style(&out, 3), (Some("5".into()), Some("255".into())));
for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] {
let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap();
assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}");
@@ -196,7 +264,7 @@ fn colours_map_to_the_index_palette_or_true_colour() {
fn units_scale_coordinates_and_set_insunits() {
let d = shapes_doc();
let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS");
let max_x = |o: &DxfOutput| all(&entities(o)[1], 10).into_iter().fold(f64::MIN, f64::max);
let max_x = |o: &DxfOutput| all(&entities(o)[2], 10).into_iter().fold(f64::MIN, f64::max);
let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap();
assert_eq!(insunits(&mm).as_deref(), Some("4"));
assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5);
@@ -208,9 +276,9 @@ fn units_scale_coordinates_and_set_insunits() {
assert_eq!(insunits(&scaled).as_deref(), Some("0"));
assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5);
// Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked.
assert_eq!(get(&entities(&scaled)[1], 370), Some("35"));
assert_eq!(get(&entities(&scaled)[2], 370), Some("35"));
let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&heavy)[1], 370), Some("70"));
assert_eq!(get(&entities(&heavy)[2], 370), Some("70"));
assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err());
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
}
@@ -238,7 +306,7 @@ fn layers_become_dxf_layers() {
assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out");
assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off");
let on: Vec<String> = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect();
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Notes"]);
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Dims_ _top_", "Notes", "Notes"]);
// Before 2000: capitals, letters, digits and $-_ only.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let names: Vec<String> = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect();
@@ -270,18 +338,21 @@ fn dashes_become_linetypes_and_opacity_transparency() {
fn alter_paths_writes_strokes_as_filled_outlines() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), ["HATCH", "HATCH", "HATCH"]);
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[1], 91), Some("2"));
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// Preserve Appearance outlines only the strokes a line can't draw.
let mut d = shapes_doc();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
st.align = vectorcraft_doc::StrokeAlign::Outside;
}
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["HATCH", "HATCH", "SPLINE"]);
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["HATCH", "LWPOLYLINE", "SPLINE"]);
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
}
@@ -308,7 +379,9 @@ fn type_is_text_or_outlines() {
// Outline Text, and Preserve Appearance: the glyphs as filled outlines.
for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] {
let k = kinds(&export(&d, &o).unwrap());
assert!(!k.is_empty() && k.iter().all(|k| k == "HATCH"), "{k:?}");
assert!(k.iter().any(|k| k == "HATCH"), "{k:?}");
assert!(k.iter().all(|k| matches!(k.as_str(), "HATCH" | "LWPOLYLINE" | "SPLINE")), "{k:?}");
assert_eq!(k.first().map(String::as_str), Some("SPLINE"), "a glyph's outline before its hatch");
}
}
@@ -336,7 +409,7 @@ fn images_link_png_or_jpeg_files() {
link: None,
placement: Default::default(),
};
vec![Node::new(d.alloc_id(), NodeKind::Image(im))]
vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))]
});
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
let out = export(&d, &opts(&d)).unwrap();
@@ -367,5 +440,5 @@ fn crop_leaves_out_art_outside_the_region() {
// Only the ellipse (120..180) reaches into 115..200.
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 3);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
+86 -1
View File
@@ -1,7 +1,7 @@
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use moxcms::{
ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
@@ -27,6 +27,9 @@ fn intent_index(i: Intent) -> usize {
type Xf = Option<Arc<TransformF32Executor>>;
/// A direct transform's cache key: destination name, intent index, black-point compensation.
type DirectKey = (String, usize, bool);
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
pub struct IccProfile {
pub name: String,
@@ -37,6 +40,21 @@ pub struct IccProfile {
srgb: ColorProfile,
to_srgb: [OnceLock<Xf>; 4],
from_srgb: [OnceLock<Xf>; 4],
/// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent
/// and black-point compensation.
direct: Mutex<Vec<(DirectKey, Option<Arc<Direct>>)>>,
}
/// A transform from one profile into an RGB profile without passing through sRGB: into the
/// destination's primaries with linear tone curves, where black-point compensation is a linear
/// remap, then onto its own curves.
struct Direct {
to_linear: Arc<TransformF32Executor>,
/// Linear → the destination's curves; `None` when the destination isn't a matrix profile
/// (the first transform goes straight into it, without compensation).
to_dest: Option<Arc<TransformF32Executor>>,
/// The source black's luminance in the linear destination (0: no compensation).
black: f32,
}
impl std::fmt::Debug for IccProfile {
@@ -72,6 +90,7 @@ impl IccProfile {
srgb: ColorProfile::new_srgb(),
to_srgb: Default::default(),
from_srgb: Default::default(),
direct: Mutex::new(Vec::new()),
})
}
@@ -135,6 +154,72 @@ impl IccProfile {
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
}
/// Device values (RGB / CMYK / Gray, 0..1) → `dest`'s RGB, converted directly with `intent`:
/// not through sRGB, so colours outside sRGB keep their place in a wider space. With `bpc`
/// (and an intent other than absolute), this profile's black maps onto the destination's
/// black (black-point compensation, the darkest ink the profile prints becoming RGB 0 rather
/// than a dark grey). `None` when `dest` isn't RGB or no transform can be built.
pub fn to_rgb_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option<[f32; 3]> {
let t = self.direct_xf(dest, intent, bpc && intent != Intent::AbsoluteColorimetric)?;
let mut src = v.to_vec();
src.resize(self.channels(), 0.0);
let mut lin = [0.0f32; 3];
t.to_linear.transform(&src, &mut lin).ok()?;
let Some(to_dest) = &t.to_dest else { return Some(lin.map(|x| x.clamp(0.0, 1.0))) };
if t.black > 0.0 {
lin = lin.map(|x| (x - t.black) / (1.0 - t.black));
}
let lin = lin.map(|x| x.clamp(0.0, 1.0));
let mut out = [0.0f32; 3];
to_dest.transform(&lin, &mut out).ok()?;
Some(out.map(|x| x.clamp(0.0, 1.0)))
}
fn direct_xf(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Arc<Direct>> {
if dest.kind != ProfileKind::Rgb {
return None;
}
let key = (dest.name.clone(), intent_index(intent), bpc);
let mut cache = self.direct.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, t)) = cache.iter().find(|(k, _)| *k == key) {
return t.clone();
}
let t = self.build_direct(dest, intent, bpc).map(Arc::new);
cache.push((key, t.clone()));
t
}
fn build_direct(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Direct> {
let d = &dest.profile;
let matrix = d.red_trc.is_some() && d.green_trc.is_some() && d.blue_trc.is_some();
if !matrix {
let xf = self.profile.create_transform_f32(self.layout(), d, Layout::Rgb, Self::opts(intent)).ok()?;
return Some(Direct { to_linear: xf, to_dest: None, black: 0.0 });
}
let mut linear = d.clone();
for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] {
*trc = Some(ToneReprCurve::Parametric(vec![1.0]));
}
// A coding-independent code point names the transfer curve outright (moxcms's sRGB has
// one) and would override the linear curves.
linear.cicp = None;
let to_linear = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?;
let to_dest = linear.create_transform_f32(Layout::Rgb, d, Layout::Rgb, Self::opts(Intent::RelativeColorimetric)).ok()?;
// The source black: the darkest colour the profile reproduces, found as lcms does for an
// output profile (black sent into the profile and read back), made neutral.
let black = if bpc && self.kind == ProfileKind::Cmyk {
let inks = self.from_srgb([0.0; 3], Intent::RelativeColorimetric)?;
let mut lin = [0.0f32; 3];
to_linear.transform(&inks, &mut lin).ok()?;
let y = |c: &moxcms::Xyzd| c.y as f32;
let lum = lin[0] * y(&d.red_colorant) + lin[1] * y(&d.green_colorant) + lin[2] * y(&d.blue_colorant);
lum.clamp(0.0, 0.5)
} else {
0.0
};
Some(Direct { to_linear, to_dest: Some(to_dest), black })
}
/// Whether transforms can be built for this profile (checked at registration).
pub fn usable(&self) -> bool {
self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
+32 -1
View File
@@ -356,10 +356,37 @@ impl Cms {
}
}
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour). An ICC
/// CMYK profile converts as [`Self::cmyk_to_rgb`] does into sRGB (with the settings'
/// black-point compensation), so what shows is what an RGB export writes.
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
if let (false, CmykSpace::Icc(p)) = (absolute, &self.cmyk)
&& let Some(srgb) = builtin_rgb_cache(SRGB)
&& let Some(v) = p.to_rgb_of(&srgb, &cmyk.map(|v| v.clamp(0.0, 1.0)), Intent::RelativeColorimetric, self.settings.bpc)
{
return v;
}
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
}
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
/// RGB space holds (Wide Gamut RGB keeps CMYK cyan that sRGB can't show), and with the
/// settings' black-point compensation. The built-in CMYK spaces go through sRGB as before
/// (Generic CMYK compensates in its own model).
pub fn cmyk_to_rgb(&self, cmyk: [f32; 4], intent: Intent) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
let src = match &self.cmyk {
CmykSpace::Icc(p) => Some(p.clone()),
CmykSpace::Generic(_) | CmykSpace::Device => None,
};
let dest = match &self.rgb {
RgbSpace::Icc(p) => Some(p.clone()),
RgbSpace::Srgb => builtin_rgb_cache(SRGB),
};
src.zip(dest)
.and_then(|(s, d)| s.to_rgb_of(&d, &cmyk, intent, self.settings.bpc))
.unwrap_or_else(|| self.srgb_to_rgb(self.cmyk_to_srgb(cmyk, false)))
}
/// Display sRGB → working CMYK with `intent`.
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc)
@@ -424,6 +451,10 @@ impl Cms {
let [c, m, y, k] = self.to_cmyk(c, intent);
Color::Cmyk { c, m, y, k }
}
(Color::Cmyk { c, m, y, k }, Model::Rgb) => {
let [r, g, b] = self.cmyk_to_rgb([*c, *m, *y, *k], intent);
Color::Rgb { r, g, b }
}
(_, Model::Rgb) => {
let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c));
Color::Rgb { r, g, b }
+51
View File
@@ -60,3 +60,54 @@ fn a_loaded_profile_is_embedded_as_loaded() {
register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap();
assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]);
}
/// Settings with an ICC CMYK profile (Generic CMYK's, loaded as a file is) and Wide Gamut RGB.
fn icc_cmyk(bpc: bool) -> Cms {
let name = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Test ICC CMYK".into())).unwrap().name;
Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: name, bpc, ..ColorSettings::default() }).unwrap()
}
#[test]
fn icc_cmyk_converts_into_a_wide_rgb_space_directly_without_clipping_to_srgb() {
let wide = icc_cmyk(true);
let cyan = [1.0, 0.0, 0.0, 0.0];
let direct = wide.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
// Through sRGB, cyan clips to sRGB's gamut first: in the wider space that reads as a red
// channel well above zero.
let hub = wide.srgb_to_rgb(wide.cmyk_to_srgb(cyan, false));
assert!(direct[0] + 0.15 < hub[0], "direct {direct:?}, through sRGB {hub:?}");
// Same colour either way where sRGB holds it: a mid grey and paper white.
for inks in [[0.0, 0.0, 0.0, 0.5], [0.0; 4]] {
let (d, h) = (wide.cmyk_to_rgb(inks, Intent::RelativeColorimetric), wide.srgb_to_rgb(wide.cmyk_to_srgb(inks, false)));
assert!(d.iter().zip(h).all(|(a, b)| (a - b).abs() < 0.02), "{inks:?}: direct {d:?}, through sRGB {h:?}");
}
// Cms::convert takes the direct route for CMYK.
let Color::Rgb { r, .. } = wide.convert(&Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 }, Model::Rgb, Intent::RelativeColorimetric) else {
panic!("RGB")
};
assert!((r - direct[0]).abs() < 1e-6);
}
#[test]
fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
let rich = [0.75, 0.68, 0.67, 0.9];
let (on, off) = (icc_cmyk(true).cmyk_to_rgb(rich, Intent::RelativeColorimetric), icc_cmyk(false).cmyk_to_rgb(rich, Intent::RelativeColorimetric));
// Without compensation the profile's black is a dark grey; with it, rich black goes most of
// the way to RGB black (not all: it isn't quite the darkest ink the profile prints).
assert!(off.iter().all(|v| *v > 0.1), "without: {off:?}");
assert!(on.iter().zip(off).all(|(a, b)| *a < 0.08 && *a < b * 0.5), "with: {on:?}, without: {off:?}");
// The screen shows what an sRGB conversion writes, compensated alike.
let srgb = |bpc| {
let c = icc_cmyk(bpc);
Cms::new(&ColorSettings { rgb: SRGB.into(), ..c.settings().clone() }).unwrap()
};
for bpc in [true, false] {
let c = srgb(bpc);
assert_eq!(c.cmyk_to_srgb(rich, false), c.cmyk_to_rgb(rich, Intent::RelativeColorimetric), "bpc {bpc}");
}
// White is untouched, and absolute colorimetric never compensates.
let white = icc_cmyk(true).cmyk_to_rgb([0.0; 4], Intent::RelativeColorimetric);
assert!(white.iter().all(|v| *v > 0.99), "{white:?}");
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
assert_eq!(abs(true), abs(false));
}
+36 -2
View File
@@ -131,10 +131,18 @@ impl Gradient {
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
self.stops.reverse();
for s in &mut self.stops {
s.offset = 1.0 - s.offset;
for i in 0..self.stops.len() {
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
// one the next stop (not yet mirrored) had, measured from the other end.
let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint);
if let Some(s) = self.stops.get_mut(i) {
s.offset = 1.0 - s.offset;
s.midpoint = mid;
}
}
}
pub fn sort(&mut self) {
@@ -569,6 +577,32 @@ mod tests {
assert_eq!(g.stops[0].offset, 0.0);
}
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
/// the other way round.
#[test]
fn reverse_mirrors_midpoints() {
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
let three =
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
for g in [two, three] {
let mut r = g.clone();
r.reverse();
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
for i in 0..=20 {
let t = i as f32 / 20.0;
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
}
r.reverse();
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
}
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-9
}
+827 -19
View File
@@ -1,6 +1,10 @@
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
//! headers) and CSS custom properties (written only).
//! headers), swatch exchange `.ase` files (binary: solid colors in their own model, global, spot
//! and process colors, color groups), color books `.acb` (read only: the books a user owns, such
//! as a Pantone book) and CSS custom properties (written only).
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
@@ -13,18 +17,22 @@ pub enum PaletteFormat {
Native,
/// `.gpl`: a plain-text RGB palette many paint and design tools read.
Gpl,
/// `.ase`: a swatch exchange file (binary): solid colors in their own model, global, spot or
/// process, and color groups.
Ase,
/// `.css`: custom properties on `:root` (colours and gradients; written only).
Css,
}
impl PaletteFormat {
pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css];
pub const ALL: [PaletteFormat; 4] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase, PaletteFormat::Css];
/// The format's id, which is also its extension.
pub fn id(self) -> &'static str {
match self {
PaletteFormat::Native => "vcswatches",
PaletteFormat::Gpl => "gpl",
PaletteFormat::Ase => "ase",
PaletteFormat::Css => "css",
}
}
@@ -32,6 +40,7 @@ impl PaletteFormat {
match self {
PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)",
PaletteFormat::Gpl => "GPL Palette (.gpl)",
PaletteFormat::Ase => "Swatch Exchange (.ase)",
PaletteFormat::Css => "CSS Custom Properties (.css)",
}
}
@@ -44,6 +53,23 @@ impl PaletteFormat {
pub fn readable(self) -> bool {
self != PaletteFormat::Css
}
/// Are files of this format binary rather than text?
pub fn binary(self) -> bool {
self == PaletteFormat::Ase
}
/// Does [`write`] keep a swatch with `paint` in a file of this format? None and patterns are
/// never written; `.gpl` and `.ase` hold solid colors only, and CSS holds no freeform gradients.
pub fn holds(self, paint: &Paint) -> bool {
match paint {
Paint::Solid { .. } => true,
Paint::Gradient(g) => match self {
PaletteFormat::Native => true,
PaletteFormat::Css => g.gradient.kind != GradientKind::Freeform,
PaletteFormat::Gpl | PaletteFormat::Ase => false,
},
Paint::None | Paint::Pattern { .. } => false,
}
}
}
/// The native file: a header around the library.
@@ -58,12 +84,25 @@ struct NativeFile {
const NATIVE_FORMAT: &str = "vcswatches";
const GPL_HEADER: &str = "GIMP Palette";
const GPL_GROUP: &str = "# Group:";
const ASE_SIGNATURE: &[u8] = b"ASEF";
const ASE_GROUP_START: u16 = 0xC001;
const ASE_GROUP_END: u16 = 0xC002;
const ASE_COLOR: u16 = 0x0001;
/// The most colors and groups read from one `.ase` file.
const ASE_MAX_ENTRIES: usize = 100_000;
/// The most UTF-16 code units of a name that are kept.
const ASE_MAX_NAME: usize = 1024;
const ASE_CUT_SHORT: &str = "the swatch exchange file is cut short";
const ACB_SIGNATURE: &[u8] = b"8BCB";
const ACB_CUT_SHORT: &str = "the color book is cut short";
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
match format {
/// Write `lib` in `format` → the file's bytes. Pattern swatches (their tiles live in a document)
/// and None are left out; `.gpl` keeps solid colours only, as 8-bit RGB, and `.ase` keeps solid
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
/// [`read_bytes`] reads from one file.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
let keep = |w: &&Swatch| format.holds(&w.paint);
let text = match format {
PaletteFormat::Native => {
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
let library = SwatchLibrary {
@@ -75,8 +114,10 @@ pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
serde_json::to_string_pretty(&file).unwrap_or_default()
}
PaletteFormat::Gpl => write_gpl(lib),
PaletteFormat::Ase => return write_ase(lib),
PaletteFormat::Css => write_css(lib, keep),
}
};
Ok(text.into_bytes())
}
/// One line of text: no line breaks or tabs.
@@ -172,6 +213,102 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
out
}
/// `.ase`, as [`read_ase`] reads it: version 1.0, the ungrouped colors, then each color group as a
/// group start (its name), its colors and a group end, also for a group without colors. A color is
/// written in its own model: RGB, CMYK and gray components from 0 to 1 (gray as a level, 1 is
/// white), Lab lightness as a fraction of 100 and a and b from −128 to 127. Components are clamped
/// to those ranges, and a component that isn't a finite number is written as 0. The color type is
/// 1 for a spot color, 0 for another global color and 2 for a process color.
///
/// A swatch exchange file holds solid colors only: gradients, patterns and None are left out, and
/// a tint swatch is written as the color it shows, a process color without its link to its base.
/// The file has no library name ([`read_ase`] names a library after its file). More than
/// [`ASE_MAX_ENTRIES`] colors and groups is an error.
fn write_ase(lib: &SwatchLibrary) -> Result<Vec<u8>, String> {
let solid = |list: &[Swatch]| list.iter().filter(|w| w.paint.color().is_some()).count();
let entries = solid(&lib.swatches) + lib.groups.iter().map(|g| 1 + solid(&g.swatches)).sum::<usize>();
if entries > ASE_MAX_ENTRIES {
return Err(format!(
"VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries}"
));
}
let mut body = vec![];
let mut blocks = put_ase_colors(&mut body, &lib.swatches)?;
for g in &lib.groups {
let mut name = vec![];
put_ase_name(&mut name, &g.name)?;
put_ase_block(&mut body, ASE_GROUP_START, &name)?;
blocks += 2 + put_ase_colors(&mut body, &g.swatches)?;
put_ase_block(&mut body, ASE_GROUP_END, &[])?;
}
let blocks = u32::try_from(blocks).map_err(|_| "too many swatch exchange blocks".to_string())?;
let mut out = ASE_SIGNATURE.to_vec();
out.extend(1u16.to_be_bytes());
out.extend(0u16.to_be_bytes());
out.extend(blocks.to_be_bytes());
out.extend(body);
Ok(out)
}
/// The color blocks of the solid colors in `list` → how many were written.
fn put_ase_colors(out: &mut Vec<u8>, list: &[Swatch]) -> Result<usize, String> {
let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
let ab = |v: f32| if v.is_finite() { v.clamp(-128.0, 127.0) } else { 0.0 };
let mut n = 0;
for (w, color) in list.iter().filter_map(|w| Some((w, w.paint.color()?))) {
let mut body = vec![];
put_ase_name(&mut body, &w.name)?;
let (model, values): (&[u8; 4], Vec<f32>) = match color {
Color::Rgb { r, g, b } => (b"RGB ", vec![unit(r), unit(g), unit(b)]),
Color::Cmyk { c, m, y, k } => (b"CMYK", vec![unit(c), unit(m), unit(y), unit(k)]),
Color::Lab { l, a, b } => (b"LAB ", vec![unit(l / 100.0), ab(a), ab(b)]),
Color::Gray { k } => (b"Gray", vec![1.0 - unit(k)]),
};
body.extend(model);
body.extend(values.iter().flat_map(|v| v.to_be_bytes()));
let kind: u16 = if w.spot {
1
} else if w.global {
0
} else {
2
};
body.extend(kind.to_be_bytes());
put_ase_block(out, ASE_COLOR, &body)?;
n += 1;
}
Ok(n)
}
/// A block: its type, the length of its body and the body.
fn put_ase_block(out: &mut Vec<u8>, kind: u16, body: &[u8]) -> Result<(), String> {
let len = u32::try_from(body.len()).map_err(|_| "a swatch exchange block is too long".to_string())?;
out.extend(kind.to_be_bytes());
out.extend(len.to_be_bytes());
out.extend_from_slice(body);
Ok(())
}
/// A name as `.ase` stores it: a `u16` count of UTF-16 code units, the terminating zero included,
/// then the units. NUL characters are left out, and a name longer than [`ASE_MAX_NAME`] code units
/// is cut before the character that would pass that limit.
fn put_ase_name(out: &mut Vec<u8>, name: &str) -> Result<(), String> {
let mut units: Vec<u16> = vec![];
for c in name.chars().filter(|&c| c != '\0') {
let mut buf = [0; 2];
let encoded = c.encode_utf16(&mut buf);
if units.len() + encoded.len() > ASE_MAX_NAME {
break;
}
units.extend_from_slice(encoded);
}
units.push(0);
let count = u16::try_from(units.len()).map_err(|_| "a swatch exchange name is too long".to_string())?;
out.extend(count.to_be_bytes());
out.extend(units.iter().flat_map(|u| u.to_be_bytes()));
Ok(())
}
/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the
/// file doesn't.
pub fn read(text: &str, name: &str) -> Result<SwatchLibrary, String> {
@@ -199,16 +336,38 @@ pub fn sniff(text: &str) -> bool {
t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT))
}
/// Read the bytes of a library file: a swatch exchange `.ase` file, else a `.vcswatches` or
/// `.gpl` library ([`read`]). `name` names it when the file doesn't.
pub fn read_bytes(bytes: &[u8], name: &str) -> Result<SwatchLibrary, String> {
if bytes.starts_with(ASE_SIGNATURE) {
return read_ase(bytes, name);
}
if bytes.starts_with(ACB_SIGNATURE) {
return read_acb(bytes, name).map_err(|e| if e == ASE_CUT_SHORT { ACB_CUT_SHORT.into() } else { e });
}
read(&String::from_utf8_lossy(bytes), name)
}
/// Are `bytes` a library [`read_bytes`] understands? Text that isn't valid UTF-8 is checked as
/// [`read_bytes`] reads it, with the invalid bytes replaced (its first KiB is enough).
pub fn sniff_bytes(bytes: &[u8]) -> bool {
bytes.starts_with(ASE_SIGNATURE)
|| bytes.starts_with(ACB_SIGNATURE)
|| std::str::from_utf8(bytes).is_ok_and(sniff)
|| sniff(&String::from_utf8_lossy(bytes.get(..1024).unwrap_or(bytes)))
}
/// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are
/// named by their values; repeated names get a number.
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colors are
/// named by their values and unnamed groups "Color Group"; a name a color or group already has gets
/// a number ([`UniqueNames`]).
fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
let mut names: Vec<String> = vec![];
let mut names = UniqueNames::default();
for line in text.lines().skip(1) {
let line = line.trim();
if let Some(g) = line.strip_prefix(GPL_GROUP) {
lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] });
lib.groups.push(SwatchGroup { name: names.unique(group_name(g)), swatches: vec![] });
continue;
}
if let Some(n) = line.strip_prefix("Name:") {
@@ -221,9 +380,7 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
let [r, g, b] = rgb[..] else { continue };
let rest = words[3..].join(" ");
let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest };
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base);
names.push(name.clone());
let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
let w = Swatch { name: names.unique(base), paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
match lib.groups.last_mut() {
Some(grp) => grp.swatches.push(w),
None => lib.swatches.push(w),
@@ -232,10 +389,326 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
lib
}
/// `.ase`, from public descriptions of the format: the layout from
/// <https://www.selapa.net/swatches/colors/fileformats.php>, the component ranges from
/// <https://github.com/nsfmc/swatch>. After the signature come, big-endian, a `u16` major (1) and
/// minor version, a `u32` block count and the blocks, each a `u16` type, a `u32` body length and
/// the body. A group start's body is a name. A color's body is a name, a four-character model
/// (`RGB `, `CMYK`, `LAB `, `Gray`), its components as `f32` (3, 4, 3 and 1 of them) and a `u16`
/// color type (0 global, 1 spot, 2 process). RGB, CMYK and gray components run from 0 to 1 (a
/// gray level of 1 is white), Lab lightness from 0 to 1 and a and b from −128 to 127. A lightness
/// above 1 is read as L* itself. A name is a `u16` count of UTF-16 code units, the terminating zero
/// included, then the units.
///
/// Color groups become groups: a group start inside an open group ends that group, and a group
/// left open ends with the file. Blocks of other types and colors in other models are skipped.
/// Unnamed colors are named by their values and unnamed groups "Color Group"; a name a color or
/// group already has gets a number ([`UniqueNames`]). A truncated file, a major version other than
/// 1 or more than [`ASE_MAX_ENTRIES`] colors and groups is an error.
fn read_ase(bytes: &[u8], fallback: &str) -> Result<SwatchLibrary, String> {
let mut data = AseData(bytes);
data.bytes(ASE_SIGNATURE.len())?;
let (major, minor) = (data.u16()?, data.u16()?);
if major != 1 {
return Err(format!("swatch exchange version {major}.{minor} isn't supported (only 1.x)"));
}
let blocks = data.u32()?;
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
let mut names = UniqueNames::default();
let (mut in_group, mut entries) = (false, 0usize);
for _ in 0..blocks {
let kind = data.u16()?;
let len = usize::try_from(data.u32()?).unwrap_or(usize::MAX);
let mut body = AseData(data.bytes(len)?);
if matches!(kind, ASE_GROUP_START | ASE_COLOR) {
entries += 1;
if entries > ASE_MAX_ENTRIES {
return Err(format!("the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups"));
}
}
match kind {
ASE_GROUP_START => {
lib.groups.push(SwatchGroup { name: names.unique(group_name(&body.name()?)), swatches: vec![] });
in_group = true;
}
ASE_GROUP_END => in_group = false,
ASE_COLOR => {
let Some(mut w) = ase_swatch(&mut body)? else { continue };
w.name = names.unique(std::mem::take(&mut w.name));
match lib.groups.last_mut().filter(|_| in_group) {
Some(g) => g.swatches.push(w),
None => lib.swatches.push(w),
}
}
// Other blocks are skipped.
_ => {}
}
}
Ok(lib)
}
/// `.acb` (a color book, read only), from public descriptions of the format: after the signature
/// come, big-endian, a `u16` version (1) and book id, the title, prefix, suffix and description
/// as strings, a `u16` color count, page size and page selector offset, a `u16` color space (0
/// RGB, 2 CMYK, 7 Lab) and the colors: each a name, a six-character catalog code and its
/// components as bytes. RGB runs from 0 to 255, CMYK ink is stored inverted (255 is no ink), Lab
/// lightness is 0 to 255 for 0 to 100 and a and b are offset by 128. A string is a `u32` count of
/// UTF-16 code units, then the units; a `$$$/key=Text` string means `Text`, and `^R`, `^C` stand
/// for ® and ©. An optional `spflspot` or `spflproc` at the end says whether the colors are spot
/// or process colors (a book without it is a spot color book).
///
/// The book's colors are named with its prefix and suffix ("PANTONE 185 C"); colors without a
/// name (the padding of a book's pages) are left out. A version other than 1, a color space other
/// than those three or more than [`ASE_MAX_ENTRIES`] colors is an error.
fn read_acb(bytes: &[u8], fallback: &str) -> Result<SwatchLibrary, String> {
let mut data = AseData(bytes);
data.bytes(ACB_SIGNATURE.len())?;
let version = data.u16()?;
if version != 1 {
return Err(format!("color book version {version} isn't supported (only 1)"));
}
let _book_id = data.u16()?;
let [title, prefix, suffix, _description] = [data.acb_string()?, data.acb_string()?, data.acb_string()?, data.acb_string()?];
let count = usize::from(data.u16()?);
if count > ASE_MAX_ENTRIES {
return Err(format!("the color book holds more than {ASE_MAX_ENTRIES} colors"));
}
let (_page_size, _page_offset) = (data.u16()?, data.u16()?);
let space = data.u16()?;
let mut colors = Vec::with_capacity(count);
for _ in 0..count {
let name = data.acb_string()?;
let _code = data.bytes(6)?;
let color = match space {
0 => {
let [r, g, b] = data.array::<3>()?;
Color::rgb8(r, g, b)
}
2 => {
let ink = |v: u8| f32::from(255 - v) / 255.0;
let [c, m, y, k] = data.array::<4>()?;
Color::cmyk(ink(c), ink(m), ink(y), ink(k))
}
7 => {
let [l, a, b] = data.array::<3>()?;
Color::lab(f32::from(l) * 100.0 / 255.0, f32::from(a) - 128.0, f32::from(b) - 128.0)
}
other => return Err(format!("color books in color space {other} aren't supported (only RGB, CMYK and Lab)")),
};
colors.push((name, color));
}
// Spot colors unless the book says process ones.
let spot = data.0.windows(8).last() != Some(b"spflproc".as_slice());
let mut lib = SwatchLibrary { name: if title.is_empty() { fallback.into() } else { title }, ..Default::default() };
let mut names = UniqueNames::default();
for (name, color) in colors.into_iter().filter(|(n, _)| !n.trim().is_empty()) {
let name = names.unique(format!("{prefix}{name}{suffix}").trim().to_string());
lib.swatches.push(Swatch { name, paint: Paint::solid(color), global: spot, spot });
}
Ok(lib)
}
/// The swatch of an `.ase` color block, `None` for a model other than RGB, CMYK, Lab and Gray.
/// Components are clamped to their ranges.
fn ase_swatch(body: &mut AseData) -> Result<Option<Swatch>, String> {
let name = body.name()?;
let unit = |v: f32| v.clamp(0.0, 1.0);
let color = match &body.array::<4>()? {
b"RGB " => {
let [r, g, b] = [body.f32()?, body.f32()?, body.f32()?];
Color::rgb(unit(r), unit(g), unit(b))
}
b"CMYK" => {
let [c, m, y, k] = [body.f32()?, body.f32()?, body.f32()?, body.f32()?];
Color::cmyk(unit(c), unit(m), unit(y), unit(k))
}
// Lightness is stored as a fraction of 100. Some writers store L* itself, which shows as a
// value above 1.
b"LAB " => {
let [l, a, b] = [body.f32()?, body.f32()?, body.f32()?];
let l = if l > 1.0 { l.min(100.0) } else { unit(l) * 100.0 };
Color::lab(l, a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
}
// A gray level (1 is white); a gray color holds its ink.
b"Gray" => Color::gray(1.0 - unit(body.f32()?)),
_ => return Ok(None),
};
// Some writers leave the color type out, which makes a process color.
let kind = if body.0.len() >= 2 { body.u16()? } else { 2 };
// Spot colors are global.
let (global, spot) = match kind {
0 => (true, false),
1 => (true, true),
_ => (false, false),
};
let name = if name.is_empty() { value_name(color) } else { name };
Ok(Some(Swatch { name, paint: Paint::solid(color), global, spot }))
}
/// A color's values in its own model, as new swatches are named ("C=10 M=20 Y=30 K=0",
/// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30").
fn value_name(c: Color) -> String {
let pct = |v: f32| (v * 100.0).round();
let byte = |v: f32| (v * 255.0).round();
match c {
Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)),
Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)),
Color::Gray { k } => format!("Gray K={}", pct(k)),
// `+ 0.0` turns a rounded -0 into 0.
Color::Lab { l, a, b } => format!("L={} a={} b={}", l.round() + 0.0, a.round() + 0.0, b.round() + 0.0),
}
}
/// `.ase` data, read field by field; reading past its end is an error.
struct AseData<'a>(&'a [u8]);
impl<'a> AseData<'a> {
fn bytes(&mut self, n: usize) -> Result<&'a [u8], String> {
let (head, rest) = self.0.split_at_checked(n).ok_or(ASE_CUT_SHORT)?;
self.0 = rest;
Ok(head)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], String> {
let (head, rest) = self.0.split_first_chunk::<N>().ok_or(ASE_CUT_SHORT)?;
self.0 = rest;
Ok(*head)
}
fn u16(&mut self) -> Result<u16, String> {
self.array().map(u16::from_be_bytes)
}
fn u32(&mut self) -> Result<u32, String> {
self.array().map(u32::from_be_bytes)
}
/// A component; one that isn't a finite number reads as 0.
fn f32(&mut self) -> Result<f32, String> {
let v = f32::from_be_bytes(self.array()?);
Ok(if v.is_finite() { v } else { 0.0 })
}
/// A color book string ([`read_acb`]): a `u32` count of UTF-16 code units, then the units,
/// with `$$$/key=Text` read as `Text` and `^R`, `^C` as ®, ©; at most [`ASE_MAX_NAME`] units
/// are kept.
fn acb_string(&mut self) -> Result<String, String> {
let n = usize::try_from(self.u32()?).unwrap_or(usize::MAX);
let (units, _) = self.bytes(n.checked_mul(2).ok_or(ASE_CUT_SHORT)?)?.as_chunks::<2>();
let units: Vec<u16> = units.iter().take(ASE_MAX_NAME).map(|u| u16::from_be_bytes(*u)).take_while(|&u| u != 0).collect();
let s = String::from_utf16_lossy(&units);
let s = match s.strip_prefix("$$$/") {
Some(key) => key.split_once('=').map_or("", |(_, text)| text).to_string(),
None => s,
};
Ok(s.replace("^R", "®").replace("^C", "©"))
}
/// A name up to its terminating zero, trimmed. A block that ends before it has no name.
fn name(&mut self) -> Result<String, String> {
if self.0.is_empty() {
return Ok(String::new());
}
let n = usize::from(self.u16()?);
let (units, _) = self.bytes(n * 2)?.as_chunks::<2>();
let units: Vec<u16> = units.iter().take(ASE_MAX_NAME).map(|u| u16::from_be_bytes(*u)).take_while(|&u| u != 0).collect();
Ok(String::from_utf16_lossy(&units).trim().to_string())
}
}
/// A group's name as a file gives it, trimmed; "Color Group" when it has none.
fn group_name(name: &str) -> String {
let name = name.trim();
if name.is_empty() { "Color Group".into() } else { name.to_string() }
}
/// The swatch and group names a reader has handed out. Swatches and color groups share one set of
/// names, as in a document, and a name already taken gets the first free number ("Black 2").
#[derive(Default)]
struct UniqueNames {
taken: HashSet<String>,
/// The last number tried for each repeated name.
last: HashMap<String, usize>,
}
impl UniqueNames {
fn unique(&mut self, base: String) -> String {
if self.taken.insert(base.clone()) {
return base;
}
// Each pass tries the next number, and only finitely many names are taken.
let n = self.last.entry(base.clone()).or_insert(1);
loop {
*n += 1;
let name = format!("{base} {n}");
if self.taken.insert(name.clone()) {
return name;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Gradient, GradientPaint, GradientStop};
use proptest::prelude::*;
/// A color book's string: a `u32` count of UTF-16 code units, then the units.
fn acb_str(out: &mut Vec<u8>, s: &str) {
let units: Vec<u16> = s.encode_utf16().collect();
out.extend((units.len() as u32).to_be_bytes());
units.iter().for_each(|u| out.extend(u.to_be_bytes()));
}
/// A color book in `space` (0 RGB, 2 CMYK, 7 Lab) with `colors` (name, components) and an
/// optional spot/process marker.
fn acb(space: u16, colors: &[(&str, &[u8])], marker: &[u8]) -> Vec<u8> {
let mut b = b"8BCB".to_vec();
b.extend(1u16.to_be_bytes());
b.extend(3000u16.to_be_bytes());
for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] {
acb_str(&mut b, s);
}
b.extend((colors.len() as u16).to_be_bytes());
b.extend(7u16.to_be_bytes());
b.extend(0u16.to_be_bytes());
b.extend(space.to_be_bytes());
for (name, comps) in colors {
acb_str(&mut b, name);
b.extend(b"TST001");
b.extend(*comps);
}
b.extend(marker);
b
}
/// #831: color books (`.acb`) are read: their colors in RGB, CMYK or Lab, named with the book's
/// prefix and suffix, spot unless the book says process; page padding is left out.
#[test]
fn color_books_are_read() {
let lib = read_bytes(&acb(0, &[("185", &[228, 0, 43]), ("", &[0, 0, 0]), ("Red 032", &[239, 51, 64])], b"spflspot"), "fallback").unwrap();
assert_eq!(lib.name, "Test Book®");
let names: Vec<&str> = lib.swatches.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["TEST 185 C", "TEST Red 032 C"]);
assert!(lib.swatches.iter().all(|s| s.spot && s.global));
assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(228, 0, 43)));
// CMYK ink is stored inverted; a book marked process gives process colors.
let lib = read_bytes(&acb(2, &[("Cyan", &[0, 255, 255, 255])], b"spflproc"), "x").unwrap();
assert_eq!(lib.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0)));
assert!(!lib.swatches[0].spot && !lib.swatches[0].global);
// Lab: lightness 0..255 for 0..100, a and b offset by 128; no marker: spot.
let lib = read_bytes(&acb(7, &[("Grey", &[255, 128, 128])], b""), "x").unwrap();
assert_eq!(lib.swatches[0].paint.color(), Some(Color::lab(100.0, 0.0, 0.0)));
assert!(lib.swatches[0].spot);
assert!(sniff_bytes(&acb(0, &[], b"")));
// Unsupported versions and color spaces, and short files, are errors.
let mut v2 = acb(0, &[], b"");
v2[5] = 2;
assert!(read_bytes(&v2, "x").is_err());
assert!(read_bytes(&acb(1, &[("x", &[1, 2, 3])], b""), "x").is_err());
let full = acb(0, &[("185", &[228, 0, 43])], b"");
for cut in 4..full.len() {
assert!(read_bytes(&full[..cut], "x").is_err(), "{cut} bytes");
}
let mut huge = b"8BCB\0\x01\0\x01".to_vec();
huge.extend(u32::MAX.to_be_bytes());
assert!(read_bytes(&huge, "x").is_err(), "a string longer than the file");
}
fn sample() -> SwatchLibrary {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
@@ -262,10 +735,15 @@ mod tests {
}
}
/// `lib` written as text format `f`.
fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String {
String::from_utf8(write(lib, f).unwrap()).unwrap()
}
#[test]
fn native_round_trip_keeps_cmyk_spot_and_groups() {
let lib = sample();
let text = write(&lib, PaletteFormat::Native);
let text = written(&lib, PaletteFormat::Native);
assert!(sniff(&text));
let back = read(&text, "fallback").unwrap();
let mut expected = lib.clone();
@@ -278,7 +756,7 @@ mod tests {
#[test]
fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() {
let text = write(&sample(), PaletteFormat::Gpl);
let text = written(&sample(), PaletteFormat::Gpl);
assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n"));
assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}");
assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours");
@@ -294,6 +772,11 @@ mod tests {
assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]);
assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255)));
assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback");
// Colors and groups share names; an unnamed group is "Color Group".
let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap();
let groups: Vec<(&str, Vec<&str>)> =
lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]);
}
#[test]
@@ -303,7 +786,7 @@ mod tests {
assert_eq!(css_property("#FF00CC"), "--\\#ff00cc");
assert_eq!(css_property("Café 50%"), "--café-50\\%");
assert_eq!(css_property(" "), "--swatch");
let text = write(&sample(), PaletteFormat::Css);
let text = written(&sample(), PaletteFormat::Css);
assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}");
assert!(text.contains(" --ink: #"), "{text}");
assert!(text.contains("; /* spot */"));
@@ -317,8 +800,333 @@ mod tests {
swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(),
groups: vec![],
};
let text = write(&lib, PaletteFormat::Css);
let text = written(&lib, PaletteFormat::Css);
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
assert!(read(&text, "x").is_err(), "CSS is written only");
}
#[test]
fn holds_counts_the_swatches_each_format_writes() {
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
let mut lib = sample();
let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") };
mesh.gradient.kind = GradientKind::Freeform;
lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false });
lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false });
let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count();
assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css");
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}");
}
let css = written(&lib, PaletteFormat::Css);
assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}");
}
/// A swatch exchange file of `blocks` (type, body).
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut out = b"ASEF\0\x01\0\0".to_vec();
out.extend((blocks.len() as u32).to_be_bytes());
for (kind, body) in blocks {
out.extend(kind.to_be_bytes());
out.extend((body.len() as u32).to_be_bytes());
out.extend(body);
}
out
}
/// A name as `.ase` stores it.
fn ase_name(name: &str) -> Vec<u8> {
let units: Vec<u16> = name.encode_utf16().chain([0]).collect();
let mut out = (units.len() as u16).to_be_bytes().to_vec();
out.extend(units.iter().flat_map(|u| u.to_be_bytes()));
out
}
/// A color block: name, model, components and color type.
fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec<u8>) {
let mut body = ase_name(name);
body.extend(model);
body.extend(values.iter().flat_map(|v| v.to_be_bytes()));
body.extend(kind.to_be_bytes());
(ASE_COLOR, body)
}
#[test]
fn ase_reads_color_models_kinds_and_groups() {
let bytes = ase(&[
ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0),
ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1),
(ASE_GROUP_START, ase_name("Neutrals")),
ase_color("Mist", b"Gray", &[0.75], 2),
ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2),
(ASE_GROUP_END, vec![]),
ase_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2),
]);
assert!(sniff_bytes(&bytes));
let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot };
let expected = SwatchLibrary {
name: "Brand".into(),
swatches: vec![
sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false),
sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true),
sw("Paper", Color::WHITE, false, false),
],
groups: vec![SwatchGroup {
name: "Neutrals".into(),
swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)],
}],
};
assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process");
// A lightness above 1 is L* itself.
let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap();
assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0)));
// Text libraries read through the same call, also when they aren't valid UTF-8.
assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0]));
assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1);
let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n";
assert!(sniff_bytes(latin1));
assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1);
}
#[test]
fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() {
let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2);
untyped.1.truncate(untyped.1.len() - 2);
let bytes = ase(&[
ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2),
ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2),
ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0),
ase_color("Red", b"Gray", &[0.0], 2),
untyped,
ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2),
(0x0042, vec![1, 2, 3]),
(ASE_GROUP_END, vec![]),
(ASE_GROUP_START, ase_name("")),
(ASE_GROUP_START, ase_name("Red")),
ase_color("Red", b"Gray", &[1.0], 2),
]);
let lib = read_bytes(&bytes, "x").unwrap();
let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect();
assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped");
assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0");
assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process");
// A group start ends the open group, and groups and colors share names.
let groups: Vec<(&str, Vec<&str>)> =
lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]);
assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white");
}
#[test]
fn damaged_ase_files_are_errors() {
let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]);
assert_eq!(read_bytes(&good, "x").unwrap().len(), 1);
for n in ASE_SIGNATURE.len()..good.len() {
assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}");
}
let mut v2 = good.clone();
v2[5] = 2;
assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0");
// Too many colors, or too many groups (empty group starts take 6 bytes each).
let colors: Vec<(u16, Vec<u8>)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect();
assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than"));
let groups: Vec<(u16, Vec<u8>)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect();
assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than"));
assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit");
}
#[test]
fn ase_writes_each_model_kind_and_group() {
let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot };
let tint = Swatch {
name: "Ink 40%".into(),
paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 },
global: false,
spot: false,
};
// `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals.
let mut lib = sample();
lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false));
lib.swatches.push(tint);
lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)];
lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] });
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
let expected = ase(&[
ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0),
ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1),
ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2),
(ASE_GROUP_START, ase_name("Neutrals")),
ase_color("Mist", b"Gray", &[0.75], 2),
ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2),
(ASE_GROUP_END, vec![]),
(ASE_GROUP_START, ase_name("Empty")),
(ASE_GROUP_END, vec![]),
]);
assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows");
let back = read_bytes(&bytes, "Brand").unwrap();
assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays");
assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase));
assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary());
}
#[test]
fn ase_names_lose_nul_and_stop_at_the_reader_limit() {
let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))];
let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect();
let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] };
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect();
// At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji.
assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]);
}
#[test]
fn ase_clamps_components_and_writes_non_finite_ones_as_zero() {
let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false };
let swatches = vec![
sw("Lab", Color::lab(100.5, 300.0, -300.0)),
sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)),
sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)),
sw("Gray", Color::gray(1.5)),
];
let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap();
let expected = ase(&[
ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2),
ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2),
ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2),
ase_color("Gray", b"Gray", &[0.0], 2),
]);
assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0");
// Lightness 100.5 is written as 1, which reads back as L* 100.
let back = read_bytes(&bytes, "x").unwrap();
assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0)));
}
#[test]
fn ase_write_refuses_more_entries_than_the_reader_takes() {
let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false };
// The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it.
let mut lib = SwatchLibrary {
name: "x".into(),
swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(),
groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }],
};
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1));
lib.swatches.push(gray(0));
let e = write(&lib, PaletteFormat::Ase).unwrap_err();
assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}");
// A gradient is left out and is not counted.
lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone();
assert!(write(&lib, PaletteFormat::Ase).is_ok());
}
/// A color in one of the four models, in the ranges `.ase` holds.
fn arb_color() -> impl Strategy<Value = Color> {
let unit = || 0.0f32..=1.0;
prop_oneof![
(unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)),
(unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)),
unit().prop_map(Color::gray),
(0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)),
]
}
/// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch
/// (both flags off) or a gradient.
fn arb_paint() -> impl Strategy<Value = (Paint, bool, bool)> {
prop_oneof![
4 => (arb_color(), any::<bool>(), any::<bool>()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)),
1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)),
1 => Just((sample().swatches[1].paint.clone(), false, false)),
]
}
/// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script
/// but NUL, trimmed, so swatches and groups have unique names, as in a document.
fn arb_library() -> impl Strategy<Value = SwatchLibrary> {
let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12);
(swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| {
let mut n = 0;
let mut name = |prefix: &str, base: &str| {
n += 1;
format!("{prefix}{n} {base}").trim().to_string()
};
let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() };
for ((paint, global, spot), base) in loose {
lib.swatches.push(Swatch { name: name("", &base), paint, global, spot });
}
for (base, list) in groups {
let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] };
for ((paint, global, spot), base) in list {
g.swatches.push(Swatch { name: name("", &base), paint, global, spot });
}
lib.groups.push(g);
}
lib
})
}
/// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and
/// gray read back within `f32` rounding.
fn close(got: Color, want: Color) -> bool {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
match (got, want) {
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2),
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2),
(Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2),
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2),
_ => false,
}
}
/// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot
/// colors global.
fn ase_expected(list: &[Swatch]) -> Vec<Swatch> {
list.iter()
.filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot }))
.collect()
}
/// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`?
fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool {
got.len() == want.len()
&& got.iter().zip(want).all(|(g, w)| {
let color = match (&g.paint, w.paint.color()) {
(Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c),
_ => false,
};
g.name == w.name && g.global == w.global && g.spot == w.spot && color
})
}
proptest! {
#![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })]
/// A written `.ase` file reads back as the library's solid colors in their own model, with
/// their names, kinds and color groups.
#[test]
fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) {
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
prop_assert!(sniff_bytes(&bytes));
let back = read_bytes(&bytes, &lib.name).unwrap();
prop_assert_eq!(&back.name, &lib.name);
prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches);
prop_assert_eq!(back.groups.len(), lib.groups.len());
for (g, want) in back.groups.iter().zip(&lib.groups) {
prop_assert_eq!(&g.name, &want.name);
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
}
}
/// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as
/// [`PaletteFormat::holds`] counts.
#[test]
fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) {
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap();
prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f);
}
}
}
}
+191 -1
View File
@@ -293,6 +293,129 @@ impl WidthProfile {
}
}
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PressureProfile {
pub points: Vec<(f64, f64)>,
}
impl PressureProfile {
/// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to):
/// half way, so a Pressure brush draws its own value there.
pub const MID: f64 = 0.5;
/// The most samples a stroke keeps (after simplifying them).
pub const MAX_POINTS: usize = 512;
/// How far (in pressure) simplifying may move the recorded curve.
const TOLERANCE: f64 = 0.01;
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
pub fn at(&self, t: f64) -> f64 {
let p = &self.points;
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
let v = if t <= first.0 {
first.1
} else {
p.windows(2)
.find_map(|w| match w {
[a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)),
_ => None,
})
.unwrap_or(last.1)
};
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
/// A profile from (position, pressure) samples in order along the stroke: values are clamped
/// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to
/// [`Self::MAX_POINTS`]. `None` without a sample.
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
let pts: Vec<(f64, f64)> =
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
if pts.is_empty() {
return None;
}
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
// Positions only ever go forward.
let mut prev = 0.0;
for p in &mut pts {
p.0 = p.0.max(prev);
prev = p.0;
}
let keep = simplify_1d(&pts, Self::TOLERANCE);
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
let round = |v: f64| (v * 1e4).round() / 1e4;
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
}
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
if old.is_none() && added.is_none() {
return None;
}
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
let total = old_len + added_len;
if !total.is_finite() || total <= 1e-9 {
return old.or(added).cloned();
}
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
let o = old_len / total;
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
}
/// The pressure of the path reversed.
pub fn reversed(&self) -> Self {
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
}
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
pub fn sample() -> Self {
Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }
}
}
/// At most `max` of `pts`, evenly spread (the ends kept).
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
let n = pts.len();
if n <= max || max < 2 {
return pts;
}
(0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect()
}
/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through
/// them stays within `tol` (vertically) of every sample. The ends are always kept.
fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec<bool> {
let mut keep = vec![false; pts.len()];
let Some(last) = pts.len().checked_sub(1) else { return keep };
for i in [0, last] {
if let Some(k) = keep.get_mut(i) {
*k = true;
}
}
let mut stack = vec![(0, last)];
while let Some((i, j)) = stack.pop() {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue };
let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 };
let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1));
if let Some((k, d)) = far
&& d > tol
{
if let Some(f) = keep.get_mut(k) {
*f = true;
}
stack.push((i, k));
stack.push((k, j));
}
}
keep
}
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
/// preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -386,6 +509,11 @@ pub struct StrokeLayer {
/// How a gradient paint maps onto the stroke (within, along or across it).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub gradient_mode: StrokeGradientMode,
/// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive
/// brushes vary with it. It belongs to this path's shape, so appearances copied to other
/// art leave it behind ([`Appearance::without_pressure`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pressure: Option<PressureProfile>,
}
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
@@ -456,6 +584,7 @@ impl StrokeLayer {
arrow_align: ArrowAlign::Extend,
overprint: false,
gradient_mode: StrokeGradientMode::Within,
pressure: None,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
@@ -640,6 +769,15 @@ impl Appearance {
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
/// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
pub fn without_pressure(mut self) -> Self {
for it in &mut self.items {
if let AppearanceItem::Stroke(s) = it {
s.pressure = None;
}
}
self
}
pub fn fill_paint(&self) -> Paint {
self.fill().map(|f| f.paint.clone()).unwrap_or(Paint::None)
}
@@ -804,7 +942,7 @@ impl Appearance {
})
}
/// Gradient paints of the fills and strokes.
fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
pub(crate) fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
self.items.iter().filter_map(|i| match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&**g),
@@ -1103,6 +1241,58 @@ mod tests {
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
/// #852: pen pressure is kept simplified along the stroke and read back by position.
#[test]
fn pressure_profile_samples_simplify_and_interpolate() {
// A steady ramp keeps only its ends; a bump keeps its peak.
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
// A long noisy stroke stays bounded.
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Out-of-order points (a hand-edited file) still read within 0..1.
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
assert!((0.0..=1.0).contains(&odd.at(0.5)));
}
#[test]
fn pressure_profile_extends_and_reverses() {
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
// A side without pressure is half way.
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
assert_eq!(half.at(0.25), PressureProfile::MID);
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
assert_eq!(back, pressed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
+3 -1
View File
@@ -50,7 +50,9 @@ impl Document {
/// `ids` that are in the document, back to front.
pub fn paint_order(&self, ids: impl IntoIterator<Item = NodeId>) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect();
let ids: Vec<NodeId> = ids.into_iter().collect();
let paths = self.index_paths(ids.iter().copied());
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| paths.get(&id).map(|p| (p.clone(), id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
}
+8 -6
View File
@@ -527,7 +527,8 @@ impl Lerp {
(NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh,
(NodeKind::Text(_), NodeKind::Text(_))
| (NodeKind::SymbolInstance { .. }, NodeKind::SymbolInstance { .. })
| (NodeKind::Image(_), NodeKind::Image(_)) => Shape::Placed,
| (NodeKind::Image(_), NodeKind::Image(_))
| (NodeKind::PlacedDocument(_), NodeKind::PlacedDocument(_)) => Shape::Placed,
_ => match (node_path(a), node_path(b)) {
(Some((pa, ra, ma)), Some((pb, rb, mb))) => {
Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb), members: ma.zip(mb) }
@@ -561,7 +562,7 @@ impl Lerp {
}
}
}
n.mask = None;
n.extras_mut().mask = None;
n
}
Shape::Path { pair, rules, members } => {
@@ -586,9 +587,10 @@ impl Lerp {
*o = lerp_affine(*x, *y, t)
}
(NodeKind::Image(o), NodeKind::Image(x), NodeKind::Image(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
(NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
_ => {}
}
n.mask = None;
n.extras_mut().mask = None;
n
}
Shape::Other => {
@@ -612,12 +614,12 @@ impl Lerp {
for c in n.children_mut().into_iter().flatten() {
zero_ids(Arc::make_mut(c));
}
n.mask = None;
n.extras_mut().mask = None;
n
}
};
n.id = NodeId(0);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
n.blend = base.blend;
n.visible = true;
@@ -992,7 +994,7 @@ fn paints_opaquely(n: &Node) -> bool {
};
n.opacity >= 1.0
&& n.blend == vectorcraft_color::BlendMode::Normal
&& n.mask.is_none()
&& n.extras().mask.is_none()
&& n.appearance.effects.is_empty()
&& n.appearance
.items
+106
View File
@@ -136,10 +136,116 @@ fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
Inks::new(w, h, data)
}
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
// 0: white is zero, 1: black is zero (the two grayscales).
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
let extra = d.find_tag_unsigned_vec::<u16>(Tag::ExtraSamples).ok().flatten().and_then(|v| v.first().copied());
// (samples in a pixel, how many of them are its colour, is the next one alpha?)
let (samples, colour, alpha) = match kind {
tiff::ColorType::CMYKA(8 | 16) => (5, 4, true),
tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: n @ 2.. } if matches!(photometric, Some(0 | 1)) => {
(usize::from(n), 1, matches!(extra, Some(1 | 2)))
}
_ => return None,
};
if planar {
return None;
}
let n = usize::try_from(pixels(w, h)?).ok()?;
let data: Vec<u8> = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if Some(data.len()) != n.checked_mul(samples) {
return None;
}
let mut out = Vec::with_capacity(n * 4);
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
let a = f32::from(opacity) / 255.0;
let level = |v: u8| {
let v = f32::from(v) / 255.0;
if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v }
};
let [r, g, b] = match *p {
[c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]),
[v, ..] if photometric == Some(0) => [1.0 - level(v); 3],
[v, ..] => [level(v); 3],
[] => continue,
};
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
out.push(opacity);
}
image::RgbaImage::from_raw(w, h, out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cmyk_tiff_with_alpha_reads_as_rgba() {
use tiff::encoder::{TiffEncoder, colortype::CMYKA8};
// 2 × 1: full cyan opaque, then 100% K at half alpha (premultiplied: K 128, alpha 128).
let mut file = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<CMYKA8>(2, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
img.write_data(&[255, 0, 0, 0, 255, 0, 0, 0, 128, 128]).unwrap();
let bytes = file.into_inner();
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let px = extra_samples_tiff_rgba(&bytes, naive).expect("CMYKA reads");
assert_eq!(px.dimensions(), (2, 1));
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 255, 255], "cyan, opaque");
assert_eq!(px.get_pixel(1, 0).0, [0, 0, 0, 128], "the premultiplied K divided back out: black at half alpha");
// Not CMYKA: not this reader's.
assert!(extra_samples_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none());
}
/// Two 8-bit samples a pixel: gray and one extra sample, which the file's `ExtraSamples` names.
struct GrayExtra8;
impl tiff::encoder::colortype::ColorType for GrayExtra8 {
type Inner = u8;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero;
const BITS_PER_SAMPLE: &'static [u16] = &[8, 8];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2];
fn horizontal_predict(row: &[u8], result: &mut Vec<u8>) {
result.extend_from_slice(row);
}
}
/// A grayscale TIFF with an alpha channel, or with a channel that isn't one (#1466): the image
/// decoder reads neither.
#[test]
fn a_grayscale_tiff_with_an_extra_channel_reads_as_rgba() {
let gray = |extra: u16| {
let mut file = Cursor::new(Vec::new());
let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<GrayExtra8>(3, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap();
// White opaque, a gray of 64 at 80% alpha (premultiplied: 51), black with no alpha.
img.write_data(&[255, 255, 51, 204, 0, 0]).unwrap();
let bytes = file.into_inner();
assert!(image::load_from_memory(&bytes).is_err(), "the image decoder reads it now: this reader may go");
extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with an extra sample reads").into_raw()
};
assert_eq!(gray(1), [255, 255, 255, 255, 64, 64, 64, 204, 0, 0, 0, 0], "associated alpha, divided back out");
assert_eq!(gray(2), [255, 255, 255, 255, 51, 51, 51, 204, 0, 0, 0, 0], "unassociated alpha");
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
fn inks() -> Inks {
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
}
+11 -11
View File
@@ -60,7 +60,7 @@ impl<'a> LiveCorners<'a> {
/// The Live Corners of a path object.
pub fn of(node: &'a Node) -> Option<Self> {
let NodeKind::Path { path, live, .. } = &node.kind else { return None };
Self::new(path, live.as_ref())
Self::new(path, live.as_deref())
}
/// The corner at anchor `index` of `base`.
@@ -127,7 +127,7 @@ impl<'a> LiveCorners<'a> {
/// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is a
/// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they
/// are.
pub fn set_corners(path: &mut PathData, live: &mut Option<LiveShape>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
for k in corners {
if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) {
@@ -158,17 +158,17 @@ pub fn set_corners(path: &mut PathData, live: &mut Option<LiveShape>, corners: &
kinds.clear();
}
}
let l = live.get_or_insert_with(|| LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] });
let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] }));
// Radii are document lengths and corners circular, also on a rectangle from a file that kept
// an uneven scale in its transform (#442).
l.fold_scale();
match l {
match &mut **l {
LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind),
LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind),
LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind),
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
}
match live {
match live.as_deref_mut() {
Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => {
*path = std::mem::take(base);
*live = None;
@@ -220,15 +220,15 @@ mod tests {
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
let (mut path, mut live) = (star.clone(), None);
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None);
let Some(LiveShape::Path { base, radii, kinds }) = &live else { panic!("live corners: {live:?}") };
let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") };
assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0));
assert_eq!(path.anchor_count(), 12, "two corners cut");
let c = LiveCorners::new(&path, live.as_ref()).unwrap();
let c = LiveCorners::new(&path, live.as_deref()).unwrap();
assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round)));
assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path");
// Kinds alone keep it live; squaring every corner makes it the plain star again.
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer));
assert!(matches!(&live, Some(LiveShape::Path { radii, .. }) if radii.is_empty()));
assert!(matches!(live.as_deref(), Some(LiveShape::Path { radii, .. }) if radii.is_empty()));
set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round));
assert_eq!((path, live), (star, None));
}
@@ -237,16 +237,16 @@ mod tests {
#[test]
fn polygon_corners_follow_their_sides() {
let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] };
let (mut path, mut live) = (l.to_path(), Some(l.clone()));
let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone())));
set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None);
assert_eq!(path.anchor_count(), 12);
l = live.clone().unwrap();
l = *live.clone().unwrap();
l.set_sides(8);
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8]));
assert_eq!(l.to_path().anchor_count(), 16);
// Mixed radii: the new corners are sharp.
set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None);
let mut l = live.unwrap();
let mut l = *live.unwrap();
l.set_sides(7);
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0]));
// An uneven scale keeps the cuts circular: the radius scales by the mean scale.
+364 -3
View File
@@ -1,8 +1,15 @@
//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`].
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Rect;
/// Most categories (rows) and series (columns) a graph's data grid holds: the grid is built in full for drawing and
/// for the data window, so a file can't make it allocate without bound.
pub const MAX_GRAPH_CATEGORIES: usize = 10_000;
pub const MAX_GRAPH_SERIES: usize = 256;
/// The nine graph types, in the Graph tool group's order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -62,6 +69,200 @@ impl GraphKind {
GraphKind::Radar => "Radar",
}
}
/// Whether a series of this type can be drawn in a graph of type `graph` (Combine different graph types): the
/// column, stacked column, line and area types share a vertical value axis, the bar types a horizontal one.
/// Scatter, pie and radar lay their data out on their own and take no other types.
pub fn combines_with(self, graph: GraphKind) -> bool {
use GraphKind::*;
let vertical = |k| matches!(k, Column | StackedColumn | Line | Area);
let horizontal = |k| matches!(k, Bar | StackedBar);
self == graph || (vertical(self) && vertical(graph)) || (horizontal(self) && horizontal(graph))
}
}
/// A graph design (Object › Graph › Design…): named art that graphs draw in place of their default marks.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphDesign {
pub name: String,
pub art: std::sync::Arc<crate::Node>,
}
impl GraphDesign {
/// Most designs a document keeps, objects in one design's art, and characters in a design's name.
pub const MAX: usize = 1000;
pub const MAX_NODES: usize = 2000;
pub const MAX_NAME: usize = 64;
/// A design name cleaned up: trimmed, without control characters, at most [`Self::MAX_NAME`] long; `None` when
/// nothing is left.
pub fn clean_name(name: &str) -> Option<String> {
let n: String = name.trim().chars().filter(|c| !c.is_control()).take(Self::MAX_NAME).collect();
Some(n.trim().to_string()).filter(|n| !n.is_empty())
}
/// `art` as plain design art: a graph inside it becomes the plain group it draws as, so a marker never holds a
/// graph of its own (that its commands would then edit).
pub fn plain_art(art: &crate::Node) -> crate::Node {
let mut n = art.clone();
{
let mut e = n.extras_mut();
e.graph = None;
e.series_index = None;
}
if let Some(ch) = n.children_mut() {
let old = std::mem::take(ch);
*ch = old.iter().map(|c| std::sync::Arc::new(Self::plain_art(c))).collect();
}
n
}
}
impl crate::Document {
/// The graph designs a file gave, made safe to list and draw: at most [`GraphDesign::MAX`], each with a clean,
/// unique name, art of at most [`GraphDesign::MAX_NODES`] objects and no graph inside.
pub fn tidy_graph_designs(&mut self) {
let mut kept: Vec<GraphDesign> = vec![];
for d in std::mem::take(&mut self.graph_designs) {
if kept.len() >= GraphDesign::MAX {
break;
}
let Some(name) = GraphDesign::clean_name(&d.name) else { continue };
if kept.iter().any(|k| k.name == name) || d.art.count() > GraphDesign::MAX_NODES {
continue;
}
kept.push(GraphDesign { name, art: std::sync::Arc::new(GraphDesign::plain_art(&d.art)) });
}
self.graph_designs = kept;
}
}
/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the
/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or
/// across the top, or in pie wedges). `None` draws the default 9 pt black.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LabelStyles {
#[serde(skip_serializing_if = "Option::is_none")]
pub values: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub categories: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legend: Option<crate::CharStyle>,
}
impl LabelStyles {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// Graph Type › Position for pie graphs with several pies: the same size (Even), sized by their totals (Ratio), or
/// stacked one on another, sized by their totals (Stacked).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PiePosition {
#[default]
Even,
Ratio,
Stacked,
}
/// Graph Type › Sort for pie graphs: wedges in data order (None), largest first in each pie (All), or in the first
/// pie's largest-first order in every pie (First).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PieSort {
#[default]
None,
All,
First,
}
macro_rules! ids {
($t:ty { $($v:ident => $id:literal),* }) => {
impl $t {
pub fn id(self) -> &'static str {
match self { $(Self::$v => $id),* }
}
pub fn parse(s: &str) -> Option<Self> {
[$(Self::$v),*].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_default(&self) -> bool {
*self == Self::default()
}
}
};
}
ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" });
ids!(PieSort { None => "none", All => "all", First => "first" });
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ValueAxisSide {
#[default]
Left,
Right,
Both,
}
impl ValueAxisSide {
pub fn id(self) -> &'static str {
match self {
ValueAxisSide::Left => "left",
ValueAxisSide::Right => "right",
ValueAxisSide::Both => "both",
}
}
pub fn parse(s: &str) -> Option<Self> {
[ValueAxisSide::Left, ValueAxisSide::Right, ValueAxisSide::Both].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_left(&self) -> bool {
*self == ValueAxisSide::Left
}
}
/// Graph Type › Tick Marks › Length: no tick marks, short ones outside the axis, or lines across the whole plot.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TickLength {
None,
#[default]
Short,
Full,
}
impl TickLength {
pub fn id(self) -> &'static str {
match self {
TickLength::None => "none",
TickLength::Short => "short",
TickLength::Full => "full",
}
}
pub fn parse(s: &str) -> Option<Self> {
[TickLength::None, TickLength::Short, TickLength::Full].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_short(&self) -> bool {
*self == TickLength::Short
}
fn is_none(&self) -> bool {
*self == TickLength::None
}
}
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
/// the generator draws for that part (greyscale for a series fill, the part's own stroke otherwise).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SeriesPaint {
#[serde(skip_serializing_if = "Option::is_none")]
pub fill: Option<vectorcraft_color::Paint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<vectorcraft_color::Paint>,
/// Stroke weight in points, kept with [`Self::stroke`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_width: Option<f64>,
}
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
@@ -70,15 +271,26 @@ impl GraphKind {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GraphSpec {
/// Paint taken from the series' marks (Group Selection). One entry per series index; missing
/// entries keep the generator's own fill and stroke.
pub series_paints: Vec<SeriesPaint>,
pub kind: GraphKind,
/// Graph types chosen for single series (Combine different graph types), by series index; `None` and missing
/// entries draw as [`Self::kind`].
#[serde(skip_serializing_if = "Vec::is_empty")]
pub series_kinds: Vec<Option<GraphKind>>,
/// The plot rectangle (the area the graph tool dragged), document points.
pub rect: Rect,
pub series: Vec<String>,
pub categories: Vec<String>,
pub rows: Vec<Vec<f64>>,
/// Column/bar width as a % of the available category width (90).
/// Blank cells as `[category, series]`: their `rows` entry is a 0 placeholder, so a file still reads (as zeros)
/// in a version that doesn't know blanks. Cells past the end of a short row are blank too.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blanks: Vec<[usize; 2]>,
/// Column/bar width as a % of the available category width (90); past 100 the columns overlap.
pub column_width: f64,
/// Cluster width as a % (80).
/// Cluster width as a % (80); past 100 the clusters overlap.
pub cluster_width: f64,
/// Legend on the right (Graph Type options: Add Legend Across Top = false).
pub legend: bool,
@@ -86,30 +298,179 @@ pub struct GraphSpec {
pub ticks: usize,
pub axis_min: Option<f64>,
pub axis_max: Option<f64>,
/// Line graphs: mark data points, connect them, fill (area-like) the lines.
/// Graph Type › Value Axis side. Column, stacked column, line, area and scatter graphs (the bar graphs' value axis
/// runs along the bottom and stays there).
#[serde(skip_serializing_if = "ValueAxisSide::is_left")]
pub value_axis: ValueAxisSide,
/// Graph Type › Separate Scales: with the value axis on both sides, the series on the right axis
/// ([`Self::right_series`]) get a scale of their own there.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub separate_scales: bool,
/// Indexes of the series assigned to the right value axis.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub right_series: Vec<usize>,
/// The right value axis' tick values, used with separate scales: tick count (0 = automatic) and min/max override.
#[serde(skip_serializing_if = "is_zero")]
pub right_ticks: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_max: Option<f64>,
/// The labels' character styles, kept from formatting them.
#[serde(skip_serializing_if = "LabelStyles::is_default")]
pub label_styles: LabelStyles,
/// Graph Type › First Row in Front (each series' first category drawn over the next when columns overlap) and
/// First Column in Front (the first series drawn over the others); Draw Filled Lines (lines as filled bands).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub first_row_in_front: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub first_column_in_front: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub filled_lines: bool,
/// Graph Type › Add Drop Shadow (behind columns, bars, lines and pie wedges) and Add Legend Across Top (the
/// legend in rows above the plot instead of a column on its right).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub drop_shadow: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub legend_across_top: bool,
/// Pie graphs: Legends in Wedges (the series labels inside their wedges instead of a legend beside the pies),
/// Position and Sort.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub pie_legend_in_wedges: bool,
#[serde(skip_serializing_if = "PiePosition::is_default")]
pub pie_position: PiePosition,
#[serde(skip_serializing_if = "PieSort::is_default")]
pub pie_sort: PieSort,
/// Object › Graph › Marker: the graph design each series' data points (and legend swatch) are drawn with, by
/// series index, for line, scatter and radar series; `None` and missing entries draw the default square.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub series_markers: Vec<Option<String>>,
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
/// length, and tick marks per division (0 and 1 = one, at the labels).
#[serde(skip_serializing_if = "TickLength::is_short")]
pub tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub tick_marks: usize,
#[serde(skip_serializing_if = "TickLength::is_short")]
pub right_tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub right_tick_marks: usize,
/// Tick Marks for the category axis (none by default), and Draw Tick Marks Between Labels.
#[serde(skip_serializing_if = "TickLength::is_none")]
pub category_tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub category_tick_marks: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub ticks_between_labels: bool,
/// Graph Type › Add Labels: text before and after the value axes' numbers.
#[serde(skip_serializing_if = "String::is_empty")]
pub prefix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub suffix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub right_prefix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub right_suffix: String,
/// Line, scatter and radar graphs: mark data points, connect them.
pub mark_points: bool,
pub connect_points: bool,
/// Line graphs: run the lines from edge to edge of the plot; off (the default for new graphs), the points sit
/// at the centres of their categories, like the columns of a column graph. Graphs saved before the option
/// existed were drawn edge to edge and keep it.
#[serde(default = "edge_to_edge_before_the_option")]
pub edge_to_edge: bool,
/// Bounds of the art when it was last generated (detects moves/scales of the graph group).
#[serde(skip_serializing_if = "Option::is_none")]
pub placed: Option<Rect>,
}
fn at_most_one(n: &usize) -> bool {
*n <= 1
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
fn edge_to_edge_before_the_option() -> bool {
true
}
impl GraphSpec {
/// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's.
pub fn series_kind(&self, s: usize) -> GraphKind {
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
}
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
/// of [`MAX_GRAPH_SERIES`] cells).
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
let width = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_GRAPH_SERIES);
let blank: HashSet<[usize; 2]> = self.blanks.iter().copied().collect();
self.rows
.iter()
.take(MAX_GRAPH_CATEGORIES)
.enumerate()
.map(|(c, r)| (0..width).map(|s| r.get(s).copied().filter(|_| !blank.contains(&[c, s]))).collect())
.collect()
}
/// Store `cells`, a blank (`None`) as a 0 placeholder listed in `blanks`.
pub fn set_cells(&mut self, cells: Vec<Vec<Option<f64>>>) {
self.blanks =
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
}
}
impl Default for GraphSpec {
fn default() -> Self {
Self {
series_paints: vec![],
kind: GraphKind::Column,
series_kinds: vec![],
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
series: vec![],
categories: vec![],
rows: vec![vec![1.0]],
blanks: vec![],
column_width: 90.0,
cluster_width: 80.0,
legend: true,
ticks: 0,
axis_min: None,
axis_max: None,
value_axis: ValueAxisSide::Left,
separate_scales: false,
right_series: vec![],
right_ticks: 0,
right_axis_min: None,
right_axis_max: None,
series_markers: vec![],
label_styles: LabelStyles::default(),
first_row_in_front: false,
first_column_in_front: false,
filled_lines: false,
drop_shadow: false,
legend_across_top: false,
pie_legend_in_wedges: false,
pie_position: PiePosition::Even,
pie_sort: PieSort::None,
tick_length: TickLength::Short,
tick_marks: 1,
right_tick_length: TickLength::Short,
right_tick_marks: 1,
category_tick_length: TickLength::None,
category_tick_marks: 1,
ticks_between_labels: false,
prefix: String::new(),
suffix: String::new(),
right_prefix: String::new(),
right_suffix: String::new(),
mark_points: true,
connect_points: true,
edge_to_edge: false,
placed: None,
}
}
+117 -29
View File
@@ -31,6 +31,10 @@ impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
// A single object isolated: it is the only object.
if scope == Some(self.leaf) {
return self.leaf;
}
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
@@ -44,6 +48,8 @@ impl Hit {
/// Options for hit testing.
#[derive(Clone, Copy, Debug)]
pub struct HitOptions {
/// The isolated construction container, whose retained Shaper sources are hittable.
pub scope: Option<NodeId>,
/// Tolerance in document units (screen tolerance / zoom).
pub tol: f64,
/// Outline mode: only outlines are hittable.
@@ -58,21 +64,28 @@ pub struct HitOptions {
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
}
}
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
hit_test_skipping(doc, p, opt, &|_| false)
hit_test_skipping(doc, p, opt, &|_| false, true)
}
/// Topmost visible art for read-only sampling: locked objects and template layers participate,
/// including art outside the editing scope. This never changes their editability.
pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
hit_test_skipping(doc, p, opt, &|_| false, false)
}
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
let mut tops: Vec<NodeId> = vec![];
// Each round leaves out the objects found so far (and everything inside them).
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) {
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
if tops.contains(&top) {
break;
}
@@ -82,25 +95,40 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
if editable_only
&& let Some(scope) = opt.scope
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
if isolated.is_container() {
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
}
// A single object isolated (a path, an image…): only it hits, among its siblings.
chain.pop();
let parent = chain.last().and_then(|id| doc.node(*id))?;
return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
if !layer.visible || layer.locked {
continue;
}
if layer.is_template() {
if !layer.visible || editable_only && (layer.locked || layer.is_template()) {
continue;
}
// Pattern editing mode: only the tile art is editable.
if doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
if editable_only && doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
chain.push(layer.id);
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(mut h) = hit_children(layer, p, opt, skip, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
return Some(h);
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain, editable_only) {
return Some(finish(h));
}
chain.pop();
}
@@ -122,7 +150,14 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
}
fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, chain: &mut Vec<NodeId>) -> Option<Hit> {
fn hit_children(
parent: &Node,
p: Point,
opt: HitOptions,
skip: &dyn Fn(NodeId) -> bool,
chain: &mut Vec<NodeId>,
editable_only: bool,
) -> Option<Hit> {
let children = parent.children()?;
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
if parent.clips()
@@ -131,13 +166,20 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
{
return None;
}
for c in children.iter().rev() {
// Hidden, locked and template sublayers block clicks like top-level layers do.
if !c.visible || c.locked || c.is_template() || skip(c.id) {
for (i, c) in children.iter().enumerate().rev() {
if parent.extras().shaper.is_some() {
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
if (i == 0) != construction {
continue;
}
}
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
if !edits_contents(c)
if !(edits_contents(c)
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
@@ -146,13 +188,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
chain.push(c.id);
let hit = match &c.kind {
// A sublayer whose Preview is off is clicked in outline, like an outline view.
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain),
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain, editable_only),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain),
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain, editable_only),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain)
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain, editable_only)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
};
@@ -199,9 +241,12 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
}
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
NodeKind::Text(_)
| NodeKind::Image(_)
| NodeKind::PlacedDocument(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
@@ -248,7 +293,11 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
Some(s) => match s.children() {
Some(c) => c.iter().collect(),
// A single object isolated: only it.
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
None => doc
@@ -351,6 +400,20 @@ mod tests {
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
}
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
/// outline it doesn't touch.
#[test]
fn a_marquee_inside_a_background_leaves_the_background() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (back, small) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
// Reaching the background's edge takes it too.
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
}
#[test]
fn group_top_object() {
let mut d = Document::new(200.0, 200.0);
@@ -425,7 +488,7 @@ mod tests {
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default());
let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance = Appearance::default_art();
compound.appearance = Appearance::default_art().into();
d.insert(Some(l), 9, compound).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] {
@@ -485,7 +548,7 @@ mod tests {
let l = d.layers[0].id;
let mut add = |t: TextObject, perspective: Option<Affine>| {
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
n.perspective = perspective.map(|a| {
n.extras_mut().perspective = perspective.map(|a| {
Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) })
});
let id = n.id;
@@ -547,7 +610,11 @@ world",
d.insert(Some(l), 9, far).unwrap();
let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope);
assert_eq!(at(30.0, None), vec![g, a_id]);
assert_eq!(at(30.0, Some(g))[..2], [c_id, b_id], "isolated: the group's own objects");
assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects");
// Isolated, the art around the group is locked: clicks pass through it (#833).
assert!(at(5.0, Some(g)).is_empty());
assert!(at(130.0, Some(g)).is_empty());
assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none());
assert_eq!(at(5.0, None), vec![a_id]);
assert!(at(90.0, None).is_empty());
}
@@ -564,4 +631,25 @@ world",
d.node_mut(l).unwrap().visible = false;
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
}
#[test]
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let p = Point::new(10.0, 10.0);
for lock in [a, l] {
d.node_mut(lock).unwrap().locked = true;
assert!(hit_test(&d, p, HitOptions::default()).is_none());
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a);
assert!(d.node(lock).unwrap().locked);
d.node_mut(lock).unwrap().locked = false;
}
d.node_mut(a).unwrap().visible = false;
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
d.node_mut(a).unwrap().visible = true;
d.node_mut(l).unwrap().visible = false;
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
}
}
+155
View File
@@ -0,0 +1,155 @@
//! A cache of every node's index path, so a lookup by id checks one path instead of walking the
//! tree.
//!
//! The tree is edited in many places through public fields, so the cache can't be kept in step
//! with it. It is checked instead: a cached path is used only while the node at that path still
//! has the id (a check as deep as the tree). A path that no longer checks out rebuilds the cache,
//! and an id the cache doesn't have (deleted, or added since) is looked up by walking, as before.
//! An id that appears more than once (a damaged file) is never cached, so lookups keep returning
//! its first place in tree order.
//!
//! The one case a cached lookup can answer differently from a walk: a second node with an
//! existing id put in the tree *after* the cache was built, earlier in tree order than the first.
//! The lookup then returns the original node (also one with that id) instead of the new one. The
//! app never makes such a node (paste and duplicate give new ids, undo restores whole trees);
//! duplicated ids come from damaged files, which are cached afresh when opened.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
use std::sync::{Arc, RwLock};
use crate::{Node, NodeId};
/// Hasher for node ids: one multiply. Ids are small counters, not attacker-chosen keys, and the
/// default SipHash dominated the cost of building the cache.
#[derive(Default)]
pub struct IdHasher(u64);
impl Hasher for IdHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x100_0000_01b3);
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0 ^ v).wrapping_mul(0x9e37_79b9_7f4a_7c15);
}
fn finish(&self) -> u64 {
self.0 ^ (self.0 >> 29)
}
}
/// A map keyed by node id with [`IdHasher`].
pub type IdMap<V> = HashMap<NodeId, V, BuildHasherDefault<IdHasher>>;
/// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among
/// its siblings. One flat table, so building it allocates nothing per node.
#[derive(Default)]
pub(crate) struct Paths {
/// Each id's entry in `at`; `None` for an id that appears more than once.
ids: IdMap<Option<u32>>,
at: Vec<(u32, u32)>,
}
impl Paths {
/// The index path of `id`: `Some(None)` when the id appears more than once, `None` when it
/// isn't in the table.
pub(crate) fn get(&self, id: &NodeId) -> Option<Option<Vec<usize>>> {
let e = (*self.ids.get(id)?)?;
let mut path = Vec::new();
let mut cur = e;
// Each step goes to a parent entry, made before its children: at most `at.len()` steps.
for _ in 0..=self.at.len() {
let Some(&(parent, i)) = self.at.get(cur as usize) else { return Some(None) };
path.push(i as usize);
if parent == u32::MAX {
path.reverse();
return Some(Some(path));
}
cur = parent;
}
Some(None)
}
pub(crate) fn contains_key(&self, id: &NodeId) -> bool {
self.ids.contains_key(id)
}
pub(crate) fn len(&self) -> usize {
self.ids.len()
}
}
/// Lookups that had to walk (the cache didn't have the id, or its path no longer checks out)
/// before the cache is rebuilt: a rebuild costs a few walks, so rebuilding only after a few keeps
/// a lookup's cost within a small factor of a walk whatever the edit pattern (say, delete an
/// object and look the next one up, again and again).
const MISSES_PER_REBUILD: u32 = 4;
/// The cache, shared by clones of a document (each checks every path it uses against its own tree).
#[derive(Default)]
pub struct IdIndex(RwLock<Option<Arc<Paths>>>, std::sync::atomic::AtomicU32);
impl IdIndex {
/// The current cache, if one was built.
pub(crate) fn get(&self) -> Option<Arc<Paths>> {
self.0.read().ok().and_then(|g| g.clone())
}
/// Let go of the cache (a document kept in the undo history): the next lookup builds it again.
pub(crate) fn clear(&self) {
if let Ok(mut g) = self.0.write() {
*g = None;
}
self.1.store(0, std::sync::atomic::Ordering::Relaxed);
}
/// Whether a cache was built (and not cleared since).
pub(crate) fn is_built(&self) -> bool {
self.0.read().is_ok_and(|g| g.is_some())
}
/// Rebuild the cache from `layers` (one walk) and return it.
pub(crate) fn rebuild(&self, layers: &[Arc<Node>]) -> Arc<Paths> {
fn walk(nodes: &[Arc<Node>], parent: u32, out: &mut Paths) {
for (i, n) in nodes.iter().enumerate() {
let e = out.at.len() as u32;
out.at.push((parent, i as u32));
out.ids.entry(n.id).and_modify(|x| *x = None).or_insert(Some(e));
if let Some(ch) = n.children() {
walk(ch, e, out);
}
}
}
let mut out = Paths::default();
walk(layers, u32::MAX, &mut out);
let out = Arc::new(out);
if let Ok(mut g) = self.0.write() {
*g = Some(out.clone());
}
self.1.store(0, std::sync::atomic::Ordering::Relaxed);
out
}
/// Note a lookup that had to walk (an id the cache didn't have, or a path that no longer
/// checks out); true when it's time to rebuild.
pub(crate) fn missed(&self) -> bool {
self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 >= MISSES_PER_REBUILD
}
}
impl Clone for IdIndex {
fn clone(&self) -> Self {
IdIndex(RwLock::new(self.get()), Default::default())
}
}
/// A cache, not document content: two documents are equal whatever their caches hold.
impl PartialEq for IdIndex {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for IdIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("IdIndex")
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Inline graphics in text ([`crate::TextRun::inline`]): resolving each inline symbol's art bounds
//! from the document's symbols, which the text layout needs to size and place it.
use std::borrow::Cow;
use vectorcraft_geom::{Affine, Rect};
use crate::{Document, NodeId, NodeKind, TextObject};
/// The document key under which the app records each symbol's natural size (`{name: [w, h]}`).
pub const SYMBOL_SIZES: &str = "symbolSizes";
/// Half the side of the square symbol art is normalised to.
pub const SYMBOL_HALF: f64 = 10.0;
/// Usable art bounds: finite, non-degenerate.
fn usable(r: Option<Rect>) -> Option<Rect> {
r.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() >= 0.0 && r.height() > 1e-9)
}
impl Document {
/// Symbol space → the symbol's art at its natural size. Symbols made in the app keep their
/// art in a 20 × 20 square around the origin and their size as drawn in the document's
/// `symbolSizes` (an instance is placed with that scale); other symbols are as they are.
pub fn symbol_natural_xf(&self, name: &str) -> Affine {
let size = self.unknown.get(SYMBOL_SIZES).and_then(|m| m.get(name)).and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)));
match size {
Some((w, h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => {
Affine::scale_non_uniform(w / (2.0 * SYMBOL_HALF), h / (2.0 * SYMBOL_HALF))
}
_ => Affine::IDENTITY,
}
}
/// Visual bounds of the art of the symbol named `name` at its natural size (see
/// [`Self::symbol_natural_xf`]), if it exists and has a usable extent.
pub fn symbol_art_bounds(&self, name: &str) -> Option<Rect> {
let s = self.symbols.iter().find(|s| s.name == name)?;
let b = usable(s.art.visual_bounds().or_else(|| s.art.geometric_bounds()))?;
usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b)))
}
/// Does `t` have inline graphics whose resolved bounds differ from the document's symbols?
fn inline_stale(&self, t: &TextObject) -> bool {
t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol))
}
/// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed
/// when nothing changes). Renderers and exporters lay text out through this, so they never
/// depend on the cached bounds being current.
pub fn inline_resolved<'a>(&self, t: &'a TextObject) -> Cow<'a, TextObject> {
if !self.inline_stale(t) {
return Cow::Borrowed(t);
}
let mut t = t.clone();
self.resolve_text_inline(&mut t);
Cow::Owned(t)
}
/// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's
/// cached layout bounds are then cleared).
pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool {
let mut changed = false;
for r in &mut t.runs {
if let Some(a) = &mut r.inline {
let b = self.symbol_art_bounds(&a.symbol);
if a.bounds != b {
a.bounds = b;
changed = true;
}
}
}
if changed {
t.cached_bounds = None;
}
changed
}
/// Resolve the inline graphics of every text object in the layers (after opening a document
/// or changing its symbols). Returns the ids of the texts that changed: their cached layout
/// bounds were cleared and need recomputing.
pub fn resolve_inline_art(&mut self) -> Vec<NodeId> {
let mut stale = vec![];
self.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| r.inline.is_some())
&& self.inline_stale(t)
{
stale.push(n.id);
}
});
let mut out = vec![];
for id in stale {
let Some(mut t) = self.node(id).and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.clone()),
_ => None,
}) else {
continue;
};
if self.resolve_text_inline(&mut t)
&& let Some(n) = self.node_mut(id)
{
n.kind = NodeKind::Text(t);
out.push(id);
}
}
out
}
}
Loaded 100 of 808 files, more files were not shown because too many files have changed in this diff. Show more