Compare commits

..
Author SHA1 Message Date
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
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
414 changed files with 40446 additions and 3175 deletions

No files matched your search

+2 -2
View File
@@ -24,9 +24,9 @@ jobs:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
toolchain: "1.95"
- name: Fetch pinned and checksum-verified corpus
+8 -8
View File
@@ -29,16 +29,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v7
- 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@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@v7
# 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@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@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: freebsd-package
path: dist/release/*
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
+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
+41 -41
View File
@@ -43,7 +43,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v7
- 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@v7
# 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@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@v7
- 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@v7
# 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@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
@@ -167,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@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -191,13 +191,13 @@ jobs:
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v7
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Install build and packaging tools
run: |
@@ -208,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
@@ -236,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@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -259,8 +259,8 @@ jobs:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -274,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@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
@@ -292,22 +292,22 @@ jobs:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v7
# 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@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
@@ -328,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@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd
path: dist/release/*
@@ -344,18 +344,18 @@ jobs:
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v7
# 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@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
@@ -371,7 +371,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
@@ -390,18 +390,18 @@ jobs:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v7
# 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@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: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
@@ -451,7 +451,7 @@ jobs:
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@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
@@ -472,7 +472,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
+4 -4
View File
@@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v7
- 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
@@ -53,7 +53,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
@@ -66,7 +66,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: vectorcraft-windows-arm64
path: dist
+12 -1
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:
+1
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 | |
Generated
+91 -27
View File
@@ -806,6 +806,14 @@ 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"
@@ -1860,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"
@@ -1914,6 +1943,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -1921,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"
@@ -3152,6 +3206,8 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -4133,6 +4189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -4433,11 +4490,12 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"arboard",
"block2 0.6.2",
"clipboard-win",
"craft-print",
"eframe",
"egui",
"image",
@@ -4455,6 +4513,8 @@ dependencies = [
"vectorcraft-engine",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wayland-client",
"wayland-protocols",
"webbrowser",
"windows",
"winit",
@@ -4464,7 +4524,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4475,7 +4535,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4487,7 +4547,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4503,7 +4563,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4524,7 +4584,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4536,7 +4596,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4553,7 +4613,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4568,7 +4628,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4603,7 +4663,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4623,7 +4683,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"proptest",
@@ -4637,7 +4697,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4649,7 +4709,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"log",
@@ -4668,7 +4728,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4682,7 +4742,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4694,7 +4754,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"aes",
"flate2",
@@ -4723,7 +4783,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde",
"serde_json",
@@ -4737,7 +4797,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"gif",
@@ -4762,7 +4822,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4786,7 +4846,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4803,9 +4863,10 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
"kurbo 0.13.1",
"log",
"skrifa 0.47.0",
@@ -4813,6 +4874,7 @@ dependencies = [
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"unicode-segmentation",
"vectorcraft-doc",
"vectorcraft-geom",
"windows-registry",
@@ -4821,14 +4883,14 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-tools"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4843,7 +4905,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4856,7 +4918,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"craft-ui",
"egui",
@@ -4889,7 +4951,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -5999,7 +6061,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde_json",
]
@@ -6183,6 +6245,7 @@ dependencies = [
"displaydoc",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
@@ -6191,6 +6254,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+2 -1
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.9.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" }
+2 -2
View File
@@ -217,7 +217,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
speaks English, German, Japanese, Korean, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, 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.
@@ -226,7 +226,7 @@ The scores are self-assessed from our code against Illustrator's public document
- 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;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (29 of 57 still missing) and the Effect Gallery;
- 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.
+11 -9
View File
@@ -6,7 +6,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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%) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -26,7 +26,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| **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 28/57, 3D 1/4 (initial Revolve) |
| **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 1/4 (initial Revolve) |
| **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 |
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
@@ -52,7 +52,7 @@ everything is measured from our code against Illustrator's public documentation.
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization (12 key languages) | 54% (measured) | 70–110 | [localization-parity.md](docs/localization-parity.md) |
| Localization (12 key languages) | 63% (measured) | 60–100 | [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) |
@@ -73,7 +73,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| 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) | 48% | 24–40 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 |
| 3D and Materials | ~8% | 50–85 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
@@ -106,10 +106,10 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Indonesian | `id` | none | 0% |
| Japanese | `ja` | full | 100% |
| German | `de` | full | 98% |
| Korean | `ko` | none | 0% |
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | none | 0% |
Also shipped: Traditional Chinese (partial, 80%), Italian, Russian and Ukrainian (full), Czech (menus only).
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).
## Upcoming
@@ -120,14 +120,16 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
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: 29 filters and the Effect Gallery, 24–40 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).
## Progress log
| Date | What landed |
|---|---|
| 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-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 |
+141
View File
@@ -113,11 +113,149 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
Ok((handle, segment))
}
/// Resident memory of this process in MB (`ps`), if available.
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)
}
/// `--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");
}
for (what, mb) in &mem {
outln!(" memory {what:<45} {mb:>8.0} MB");
}
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}`")),
}
@@ -200,6 +338,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");
+7 -2
View File
@@ -402,7 +402,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;
};
@@ -419,7 +422,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;
};
+11 -1
View File
@@ -34,6 +34,10 @@ 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
@@ -51,7 +55,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# 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", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext", "NSView", "NSWindow"] }
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", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
@@ -67,6 +71,12 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std"
# 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.
winresource = "0.1"
+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);
}
}
}
+113 -47
View File
@@ -4,8 +4,9 @@
//! 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 GPU that drives a
//! display and the one that drives the primary display ([`display_gpus`]), 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.
//!
@@ -19,6 +20,10 @@
//! 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
@@ -93,6 +98,12 @@ pub fn preferred_displays(pref: Option<&str>, env: Option<PowerPreference>) -> V
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
@@ -130,6 +141,11 @@ impl Candidate {
}
}
/// 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);
@@ -266,15 +282,23 @@ fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String {
/// 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; 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
/// 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], named: Option<&str>, skip: &[String]) -> Vec<usize> {
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.
@@ -285,6 +309,7 @@ pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays:
unnamed,
c.device_type == DeviceType::Cpu,
c.backend == Backend::Gl,
!c.composites(compositor),
!drives_display,
!drives_primary,
power_rank(c.device_type, power),
@@ -400,15 +425,21 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
}
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, 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>, startup: Arc<Startup>) -> NativeAdapterSelectorMethod {
/// 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, &displays, named.as_deref(), &skip);
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &skip);
let listed: Vec<String> = candidates
.iter()
.map(|c| {
@@ -417,6 +448,8 @@ pub fn selector(power: PowerPreference, displays: Vec<DisplayGpu>, startup: Arc<
", 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 {
@@ -433,6 +466,7 @@ pub fn selector(power: PowerPreference, displays: Vec<DisplayGpu>, startup: Arc<
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:?}"),
@@ -555,14 +589,14 @@ mod tests {
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, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
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, &[]), [3, 1, 2, 0, 5, 4]);
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, &skip), [3, 1, 5, 4]);
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
@@ -571,7 +605,7 @@ mod tests {
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, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}");
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
@@ -583,9 +617,35 @@ mod tests {
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, &[]), [0, 1, 2, 3]);
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]);
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]);
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
@@ -593,11 +653,11 @@ mod tests {
#[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, &[]), [2, 0, 3, 1, 5, 4]);
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]);
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, &[]), [3, 1, 2, 0, 5, 4]);
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,
@@ -605,18 +665,21 @@ mod tests {
#[test]
fn displays_no_adapter_matches_leave_the_power_preference_in_charge() {
let c = ryzen_desktop();
let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]);
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, &[]), low, "{displays:?}");
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, &[]))[0], "NVIDIA GeForce RTX 3090");
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, &[]),
adapter_order(&c, PowerPreference::None, &[], None, &[])
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, &[]).is_empty());
assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty());
}
/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays.
@@ -624,10 +687,10 @@ mod tests {
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, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]);
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, &[]), [3, 1, 5, 4]);
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
@@ -645,6 +708,9 @@ mod tests {
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]
@@ -741,7 +807,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)");
@@ -754,7 +820,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),
[
@@ -800,10 +866,10 @@ mod tests {
};
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, &[]);
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, &skip)), ["NVIDIA GeForce GT 750M"]);
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"]);
}
@@ -831,7 +897,7 @@ mod tests {
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());
}
@@ -852,36 +918,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, &[])
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
@@ -895,9 +961,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.
@@ -912,9 +978,9 @@ 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]
+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);
}
}
}
+28 -4
View File
@@ -23,6 +23,8 @@ compile_error!("the win7 target requires --no-default-features --features window
mod automation_args;
mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
mod control_server;
#[cfg(feature = "wgpu")]
mod gpu;
@@ -32,6 +34,8 @@ 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;
@@ -303,10 +307,17 @@ dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
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("Place"), |d, (name, exts)| d.filter(name, exts)),
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(name, exts));
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.folder(folder),
None => d,
@@ -460,8 +471,9 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(_) => {
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 => {
@@ -489,6 +501,10 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
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
@@ -670,6 +686,9 @@ fn main() -> std::process::ExitCode {
// 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")]
@@ -710,13 +729,18 @@ 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, displays, 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);
}
options
};
+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();
}
}
+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

+17 -4
View File
@@ -25,10 +25,6 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.callmeBalloch]
real_name = "Riccardo Balloch"
sync_github_name = false
[people.crqch]
sync_github_name = true
@@ -73,3 +69,20 @@ 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
+2 -2
View File
@@ -14,7 +14,7 @@ Affinity files.
| 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), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| 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 |
@@ -33,7 +33,7 @@ Everything else is reported in the import warnings, one line per kind with a cou
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, stars with rounded points, several fills or strokes on one object (the
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).
+60 -35
View File
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
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(),
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
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),
@@ -165,22 +181,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
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),
+36
View File
@@ -166,3 +166,39 @@ 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));
}
@@ -20,7 +20,7 @@ patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2,
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=e127e129;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 29, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=["rectangles with straight, concave or other corner types (imported with round corners)"]
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)"]
+2 -2
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])
}
+1 -1
View File
@@ -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];
+2 -2
View File
@@ -479,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();
@@ -487,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.
+47
View File
@@ -270,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![
+5 -3
View File
@@ -349,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));
@@ -652,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);
+56 -1
View File
@@ -155,6 +155,7 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
// The ellipse: four cubics through clamped knots.
let spline = &e[3];
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
@@ -174,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();
@@ -354,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();
+1 -1
View File
@@ -942,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),
+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()
}
+5 -5
View File
@@ -562,7 +562,7 @@ impl Lerp {
}
}
}
n.mask = None;
n.extras_mut().mask = None;
n
}
Shape::Path { pair, rules, members } => {
@@ -590,7 +590,7 @@ impl Lerp {
(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 => {
@@ -614,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;
@@ -994,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
+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.
+157 -2
View File
@@ -80,6 +80,122 @@ impl GraphKind {
}
}
/// 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")]
@@ -172,9 +288,9 @@ pub struct GraphSpec {
/// 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).
/// 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,
@@ -200,6 +316,35 @@ pub struct GraphSpec {
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")]
@@ -302,6 +447,16 @@ impl Default for GraphSpec {
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,
+54 -22
View File
@@ -66,7 +66,13 @@ impl Default for HitOptions {
/// 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
@@ -75,7 +81,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
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;
}
@@ -85,7 +91,7 @@ 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();
@@ -93,27 +99,25 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
};
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
if let Some(scope) = opt.scope
if editable_only
&& let Some(scope) = opt.scope
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
&& let Some(mut chain) = doc.ancestry(scope)
{
return hit_children(container, p, opt, skip, &mut chain).map(finish);
return hit_children(container, p, opt, skip, &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(h) = hit_children(layer, p, opt, skip, &mut chain) {
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain, editable_only) {
return Some(finish(h));
}
chain.pop();
@@ -136,7 +140,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()
@@ -146,19 +157,19 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
return None;
}
for (i, c) in children.iter().enumerate().rev() {
if parent.shaper.is_some() {
if parent.extras().shaper.is_some() {
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
if (i == 0) != construction {
continue;
}
}
// Hidden, locked and template sublayers block clicks like top-level layers do.
if !c.visible || c.locked || c.is_template() || skip(c.id) {
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
if !(edits_contents(c)
|| c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|| 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)
{
@@ -167,13 +178,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 }),
};
@@ -463,7 +474,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))] {
@@ -523,7 +534,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;
@@ -606,4 +617,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")
}
}
+175 -39
View File
@@ -13,12 +13,16 @@ pub mod cmyk;
pub mod corners;
pub mod graph;
pub mod hit;
mod id_index;
pub mod inks;
mod inline;
pub mod shared;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub mod links;
pub mod live;
pub mod marks;
mod mem;
pub mod metadata;
pub mod node;
pub mod orient;
@@ -57,6 +61,12 @@ pub(crate) mod skip {
pub fn is_one(v: &f32) -> bool {
*v == 1.0
}
pub fn is_empty_vec<T>(v: &[T]) -> bool {
v.is_empty()
}
pub fn is_empty_map(v: &serde_json::Map<String, serde_json::Value>) -> bool {
v.is_empty()
}
}
pub use appearance::StrokeGradientMode;
@@ -66,15 +76,18 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength, ValueAxisSide};
pub use graph::{
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
ValueAxisSide,
};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use metadata::{CopyrightStatus, DocMetadata};
pub use node::Knockout;
pub use node::Scaling;
pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask};
pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
@@ -82,12 +95,12 @@ pub use placed_document::PlacedDocument;
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
pub use selection::{AnchorRef, Selection};
pub use selection::{AnchorRef, SegmentRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
TextWrap, VerticalAlign, WrapShape,
};
@@ -538,39 +551,48 @@ pub struct Document {
pub template: bool,
#[serde(default)]
pub units: Unit,
/// The rulers' zero point in document coordinates. Moving it changes measurements, never art.
#[serde(default, skip_serializing_if = "skip::is_default")]
pub ruler_origin: Point,
#[serde(default)]
pub color_mode: ColorMode,
pub artboards: Vec<Artboard>,
/// Top-level layers, bottom first.
pub layers: Vec<Arc<Node>>,
// The document-wide resources below are [`Shared`]: an edit copies the document (copy-on-write)
// and the undo history keeps every version, so what an edit doesn't change stays one copy.
// Reads, writes and the saved form are those of the value itself.
#[serde(default)]
pub swatches: Vec<Swatch>,
pub swatches: Shared<Vec<Swatch>>,
#[serde(default)]
pub swatch_groups: Vec<SwatchGroup>,
pub swatch_groups: Shared<Vec<SwatchGroup>>,
#[serde(default)]
pub graphic_styles: Vec<GraphicStyle>,
pub graphic_styles: Shared<Vec<GraphicStyle>>,
/// Character styles (besides the built-in [Normal Character Style], which may be redefined here).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub char_styles: Vec<TextStyleDef>,
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub char_styles: Shared<Vec<TextStyleDef>>,
/// Paragraph styles (besides the built-in [Normal Paragraph Style]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub para_styles: Vec<TextStyleDef>,
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub para_styles: Shared<Vec<TextStyleDef>>,
/// Threaded text: area-type frames one story flows through, in order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub text_threads: Vec<Vec<NodeId>>,
#[serde(default)]
pub symbols: Vec<Symbol>,
/// Object › Graph › Design…: art graphs can draw as markers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_designs: Vec<GraphDesign>,
#[serde(default)]
pub guides: Vec<Guide>,
/// View → New View… (up to 25, like Illustrator).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<SavedView>,
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub saved_selections: Vec<SavedSelection>,
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub saved_selections: Shared<Vec<SavedSelection>>,
/// Variables (data merge): named values bound to objects, applied per dataset.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub variables: Variables,
pub variables: Shared<Variables>,
#[serde(default)]
pub grid: GridPrefs,
#[serde(default = "ppi72")]
@@ -589,8 +611,8 @@ pub struct Document {
pub mask_edit: Option<MaskEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub unknown: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
pub unknown: Shared<serde_json::Map<String, serde_json::Value>>,
/// Transparency panel → Page Isolated Blending: the page is an isolated transparency group,
/// so top-level blend modes don't blend with what lies under the page.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -658,6 +680,9 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
#[serde(skip)]
index: id_index::IdIndex,
}
fn ppi72() -> f64 {
@@ -684,6 +709,7 @@ impl Document {
title: "Untitled-1".into(),
template: false,
units: Unit::Points,
ruler_origin: Point::ZERO,
color_mode: mode,
artboards: vec![Artboard {
id: 1,
@@ -693,17 +719,18 @@ impl Document {
show_cross_hairs: false,
}],
layers: vec![],
swatches,
swatch_groups,
graphic_styles: default_graphic_styles(),
char_styles: vec![],
para_styles: vec![],
swatches: swatches.into(),
swatch_groups: swatch_groups.into(),
graphic_styles: default_graphic_styles().into(),
char_styles: Default::default(),
para_styles: Default::default(),
text_threads: vec![],
symbols: vec![],
graph_designs: vec![],
guides: vec![],
views: vec![],
saved_selections: vec![],
variables: Variables::default(),
saved_selections: Default::default(),
variables: Default::default(),
grid: GridPrefs::default(),
raster_effects_ppi: 72.0,
images: BTreeMap::new(),
@@ -729,6 +756,7 @@ impl Document {
print_setup: None,
assets: vec![],
puppet: None,
index: Default::default(),
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
@@ -758,24 +786,51 @@ impl Document {
/// Find a node anywhere in the tree.
pub fn node(&self, id: NodeId) -> Option<&Node> {
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Node> {
for n in nodes {
if n.id == id {
return Some(n);
}
if let Some(ch) = n.children()
&& let Some(f) = find(ch, id)
{
return Some(f);
}
}
None
}
find(&self.layers, id)
self.index_path(id).and_then(|p| self.node_at(&p))
}
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`).
/// [`Document::node`] without building the lookup cache when there is none: for a look at a
/// document kept in the undo history ([`Document::drop_lookup_cache`]).
pub fn node_uncached(&self, id: NodeId) -> Option<&Node> {
match self.index.is_built() {
true => self.node(id),
false => self.walk_index_path(id).and_then(|p| self.node_at(&p)),
}
}
/// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a
/// table per step would add up. Lookups still work; the first one builds the cache again.
pub fn drop_lookup_cache(&self) {
self.index.clear();
}
/// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]).
pub fn has_lookup_cache(&self) -> bool {
self.index.is_built()
}
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in
/// tree order where `id` appears.
pub fn index_path(&self, id: NodeId) -> Option<Vec<usize>> {
// The cached path, while the node there still has the id ([`id_index`]).
let cached = self.index.get();
match cached.as_ref().and_then(|m| m.get(&id)) {
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p),
Some(None) => return self.walk_index_path(id),
_ => {}
}
// A stale path (the tree changed), an id the cache doesn't have (deleted or added since),
// or no cache yet: walk, and rebuild now and then.
let found = self.walk_index_path(id);
let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id));
if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) {
self.index.rebuild(&self.layers);
}
found
}
/// [`Document::index_path`] by walking the tree.
fn walk_index_path(&self, id: NodeId) -> Option<Vec<usize>> {
fn find(nodes: &[Arc<Node>], id: NodeId, path: &mut Vec<usize>) -> bool {
for (i, n) in nodes.iter().enumerate() {
path.push(i);
@@ -795,6 +850,85 @@ impl Document {
find(&self.layers, id, &mut p).then_some(p)
}
/// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an
/// edit of many objects looks them all up for the cost of one search instead of one each. Like
/// `index_path`, an id that appears twice maps to its first place in tree order.
pub fn index_paths(&self, ids: impl IntoIterator<Item = NodeId>) -> std::collections::HashMap<NodeId, Vec<usize>> {
use std::collections::{HashMap, HashSet};
fn walk(nodes: &[Arc<Node>], want: &HashSet<NodeId>, path: &mut Vec<usize>, out: &mut HashMap<NodeId, Vec<usize>>) -> bool {
for (i, n) in nodes.iter().enumerate() {
path.push(i);
if want.contains(&n.id) && !out.contains_key(&n.id) {
out.insert(n.id, path.clone());
if out.len() == want.len() {
return true;
}
}
if let Some(ch) = n.children()
&& walk(ch, want, path, out)
{
return true;
}
path.pop();
}
false
}
let mut want: HashSet<NodeId> = ids.into_iter().collect();
let mut out = HashMap::with_capacity(want.len());
// Cached paths that still check out ([`id_index`]); the rest take one walk. For a good
// share of the document, one walk is quicker than checking each cached path.
if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) {
want.retain(|id| match cache.get(id) {
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => {
out.insert(*id, p);
false
}
_ => true,
});
}
if !want.is_empty() {
let mut rest = HashMap::with_capacity(want.len());
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
out.extend(rest);
}
out
}
/// The node at index path `path` (as [`Document::index_path`] gives it).
pub fn node_at(&self, path: &[usize]) -> Option<&Node> {
let (first, rest) = path.split_first()?;
let mut cur: &Node = self.layers.get(*first)?;
for &i in rest {
cur = cur.children()?.get(i)?;
}
Some(cur)
}
/// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as
/// [`Document::node_mut`] does.
pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> {
let (first, rest) = path.split_first()?;
let mut cur: &mut Arc<Node> = self.layers.get_mut(*first)?;
for &i in rest {
cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?;
}
Some(Arc::make_mut(cur))
}
/// [`Document::ancestry`] of the node at index path `path`.
pub fn ancestry_at(&self, path: &[usize]) -> Option<Vec<NodeId>> {
let mut out = Vec::with_capacity(path.len());
let mut nodes = &self.layers;
for &i in path {
let n = nodes.get(i)?;
out.push(n.id);
if let Some(ch) = n.children() {
nodes = ch;
}
}
Some(out)
}
/// Ids from the top-level layer down to `id` inclusive.
pub fn ancestry(&self, id: NodeId) -> Option<Vec<NodeId>> {
let path = self.index_path(id)?;
@@ -1149,7 +1283,7 @@ impl Document {
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
kept.push(SavedSelection { name, objects });
}
self.saved_selections = kept;
self.saved_selections = kept.into();
}
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
@@ -1391,6 +1525,8 @@ mod tests {
}
}
#[cfg(test)]
mod tests_lookups;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
+60 -11
View File
@@ -72,10 +72,31 @@ pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo
}))
}
/// The FNV-1a offset basis: the hash of no bytes ([`fnv1a`]).
const FNV_BASIS: u64 = 0xcbf29ce484222325;
/// The 64-bit FNV-1a hash `h` of some bytes, carried on over `bytes`.
fn fnv1a(h: u64, bytes: &[u8]) -> u64 {
bytes.iter().fold(h, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
}
/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
pub fn hash_bytes(bytes: &[u8]) -> String {
let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
format!("img{h:016x}")
format!("img{:016x}", fnv1a(FNV_BASIS, bytes))
}
/// [`hash_bytes`] of everything `r` reads, read 64 KB at a time.
pub fn hash_reader(mut r: impl std::io::Read) -> std::io::Result<String> {
let mut buf = vec![0; 1 << 16];
let mut h = FNV_BASIS;
loop {
match r.read(&mut buf) {
Ok(0) => return Ok(format!("img{h:016x}")),
Ok(n) => h = fnv1a(h, buf.get(..n).unwrap_or_default()),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
}
impl ImageBlob {
@@ -115,7 +136,7 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
/// `n` and every descendant, opacity-mask art included.
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
if let Some(m) = &n.mask {
if let Some(m) = &n.extras().mask {
walk_all(&m.art, f);
}
for c in n.children().into_iter().flatten() {
@@ -127,7 +148,12 @@ impl Document {
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
/// opacity-mask art included.
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
let roots = self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art));
let roots = self
.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| &p.art));
for root in roots {
walk_all(root, &mut |n| {
if let NodeKind::Image(im) = &n.kind {
@@ -269,15 +295,19 @@ impl Document {
NodeKind::PlacedDocument(placed) => f(&mut placed.link),
_ => {}
}
if let Some(mask) = &mut n.mask {
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
walk(&mut mask.art, f);
}
for child in n.children_mut().into_iter().flatten() {
walk(child, f);
}
}
let roots =
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
walk(root, &mut f);
}
@@ -301,15 +331,19 @@ impl Document {
{
f(im);
}
if let Some(m) = &mut n.mask {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
update(&mut m.art, pick, f);
}
for c in n.children_mut().into_iter().flatten() {
update(c, pick, f);
}
}
let roots =
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
update(root, &pick, &mut f);
}
@@ -336,7 +370,14 @@ mod tests {
0,
Node::new(
id,
NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }),
NodeKind::Image(Box::new(ImageObject {
key: key.into(),
width: 4,
height: 4,
xf: Affine::IDENTITY,
link,
placement: Default::default(),
})),
),
)
.unwrap()
@@ -357,6 +398,14 @@ mod tests {
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
}
#[test]
fn a_reader_hashes_as_its_bytes_do() {
// Several 64 KB reads, and none.
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes));
assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string()));
}
#[test]
fn large_images_get_a_small_png_preview() {
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
+13 -12
View File
@@ -1222,7 +1222,7 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> {
/// edit (its points and handles).
pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
match kind {
match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
let grid = envelope_grid(*rows, *cols, points, handles)?;
Some((grid.lines(), Some(grid)))
@@ -1270,7 +1270,7 @@ impl<'a> EnvelopeMap<'a> {
/// The map of envelope node `n` (`None` for other nodes).
pub fn of(n: &'a Node) -> Option<Self> {
match &n.kind {
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, *frame),
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame),
_ => None,
}
}
@@ -1388,7 +1388,7 @@ fn carry_look(from: &Node, mut to: Node) -> Node {
to.isolate = from.isolate;
to.knockout = from.knockout;
to.knockout_shape = from.knockout_shape;
to.mask = from.mask.clone();
to.extras_mut().mask = from.extras().mask.clone();
to.appearance.effects = from.appearance.effects.clone();
to
}
@@ -1417,7 +1417,7 @@ fn map_node(n: &Node, w: &Warper) -> Node {
{
out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps));
}
if let Some(m) = &mut out.mask {
if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) {
m.art = Arc::new(map_node(&m.art, w));
}
match &mut out.kind {
@@ -1494,7 +1494,7 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node::new(n.id, NodeKind::Image(ImageObject { xf, ..im.clone() }));
let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
}
}
@@ -1523,17 +1523,18 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
_ => None,
};
let plain = |items: Vec<AppearanceItem>| {
Arc::new(Node {
appearance: Appearance { items, ..Default::default() },
let mut p = Node {
appearance: Appearance { items, ..Default::default() }.into(),
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
mask: None,
name: None,
..n.clone()
})
};
p.extras_mut().mask = None;
Arc::new(p)
};
let mut pieces = vec![];
let mut rest = vec![];
@@ -1560,7 +1561,7 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
}
Some(Node {
kind: NodeKind::Group { children: pieces, clip: false },
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() },
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(),
..n.clone()
})
}
@@ -1640,7 +1641,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
match &n.kind {
NodeKind::Blend { children, spec } => blend_expand(children, spec),
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks),
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks),
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
NodeKind::Repeat(r) => r.expand(),
NodeKind::PlacedDocument(p) => p.art(n),
@@ -1669,7 +1670,7 @@ pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node {
g.knockout = crate::Knockout::Off;
}
g.knockout_shape = n.knockout_shape;
g.mask = n.mask.clone();
g.extras_mut().mask = n.extras().mask.clone();
g
}
+420
View File
@@ -0,0 +1,420 @@
//! How much memory a document keeps that another doesn't share: what an undo step costs.
//!
//! Documents share structure (`Arc<Node>` children, [`crate::Shared`] resources), so an old
//! version kept in the undo history holds only what the edits after it replaced. The estimate
//! walks two versions side by side and visits only what isn't shared: a subtree whose `Arc` both
//! hold costs nothing and isn't entered, so the walk is about as long as the edit was (plus a pass
//! over each changed child list). One walk gives both directions: what the older version keeps
//! that the newer one doesn't (the undo step's cost) and the other way round (the cost of the redo
//! step that undoing it makes), so undo and redo needn't walk again. It counts the heap the values
//! own (struct sizes, vector capacities, strings, JSON) plus a typical allocator overhead per
//! allocation, without following every last field: an estimate in the right ballpark, for a
//! memory budget, not an exact figure.
use std::collections::HashSet;
use std::mem::size_of;
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_color::{Paint, Swatch, SwatchGroup};
use vectorcraft_geom::{Affine, Anchor, PathData, SubPath};
use crate::live::{BlendSpec, EnvelopeKind};
use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef};
use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, ImageObject, Node, NodeExtras, NodeKind, SavedSelection, Shared};
/// Bookkeeping and rounding a typical allocator adds to each allocation.
const ALLOC: usize = 16;
/// An `Arc`'s two reference counts.
const ARC: usize = 16;
/// The kinds of node this doesn't break down (meshes, repeats' settings, placed documents…): a
/// typical size of what they own besides the node.
const OTHER_KIND: usize = 256;
impl Document {
/// Roughly how many bytes of heap this document keeps that `newer` doesn't share with it: what
/// keeping this version in the undo history costs while `newer` (a later version) is kept too.
/// Visits only the parts that aren't shared (see the [module docs](self)).
pub fn retained_bytes(&self, newer: &Document) -> usize {
self.retained_bytes_each(newer).0
}
/// [`Document::retained_bytes`] both ways in one walk: what this document keeps that `other`
/// doesn't share, and what `other` keeps that this one doesn't.
pub fn retained_bytes_each(&self, other: &Document) -> (usize, usize) {
if std::ptr::eq(self, other) {
return (0, 0);
}
let own = size_of::<Document>() + ALLOC + ARC;
let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] };
w.list(&self.layers, &other.layers);
for i in 0..self.symbols.len().max(other.symbols.len()) {
match (self.symbols.get(i), other.symbols.get(i)) {
(Some(a), Some(b)) => w.pair(&a.art, &b.art),
(a, b) => {
a.into_iter().for_each(|a| w.alone(0, &a.art));
b.into_iter().for_each(|b| w.alone(1, &b.art));
}
}
}
for i in 0..self.patterns.len().max(other.patterns.len()) {
match (self.patterns.get(i), other.patterns.get(i)) {
(Some(a), Some(b)) => w.list(&a.art, &b.art),
(a, b) => {
a.into_iter().flat_map(|a| &a.art).for_each(|n| w.alone(0, n));
b.into_iter().flat_map(|b| &b.art).for_each(|n| w.alone(1, n));
}
}
}
let [mut a, mut b] = w.bytes;
a += self.lists_bytes() + self.resources_bytes(other);
b += other.lists_bytes() + other.resources_bytes(self);
(a, b)
}
/// The symbol and pattern lists themselves (their art is counted as nodes).
fn lists_bytes(&self) -> usize {
vec_bytes(&self.symbols)
+ self.symbols.iter().map(|s| s.name.capacity()).sum::<usize>()
+ vec_bytes(&self.patterns)
+ self.patterns.iter().map(|p| p.name.capacity()).sum::<usize>()
}
/// The document-wide lists and settings: a copy of the document copies those that aren't
/// [`Shared`]; the shared ones count only when `newer` has its own.
fn resources_bytes(&self, newer: &Document) -> usize {
fn shared<T>(a: &Shared<T>, b: &Shared<T>, f: impl FnOnce(&T) -> usize) -> usize {
if Shared::ptr_eq(a, b) { 0 } else { ALLOC + ARC + f(a) }
}
let mut n = 0;
n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::<usize>();
n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets);
n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::<usize>();
n += self.images.iter().map(|(k, b)| 4 * size_of::<usize>() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::<usize>();
n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes);
n += self.title.capacity();
n += shared(&self.swatches, &newer.swatches, |s| swatches_bytes(s));
n += shared(&self.swatch_groups, &newer.swatch_groups, |g| {
vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::<usize>()
});
n += shared(&self.graphic_styles, &newer.graphic_styles, |g| {
vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::<usize>()
});
let styles = |s: &Vec<TextStyleDef>| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::<usize>();
n += shared(&self.char_styles, &newer.char_styles, styles);
n += shared(&self.para_styles, &newer.para_styles, styles);
n += shared(&self.saved_selections, &newer.saved_selections, |s| {
vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::<usize>()
});
n += shared(&self.variables, &newer.variables, |v| {
// Map entries: the key and value plus a share of the tree's nodes.
let entry = 2 * size_of::<usize>();
vec_bytes(&v.variables)
+ v.variables.iter().map(|v| v.name.capacity()).sum::<usize>()
+ vec_bytes(&v.datasets)
+ v.datasets.iter().map(|d| d.name.capacity() + d.values.keys().map(|k| entry + k.capacity() + 32).sum::<usize>()).sum::<usize>()
+ v.bindings.values().map(|b| entry + 8 + b.iter().map(|s| entry + s.capacity()).sum::<usize>()).sum::<usize>()
});
n += shared(&self.unknown, &newer.unknown, map_bytes);
n
}
}
/// One estimate in progress: two documents walked side by side.
struct Walk<'a> {
docs: [&'a Document; 2],
/// For each side, every node the other document holds (by address), built the first time a
/// node of that side that the other may share has no counterpart in place: then a node moved
/// elsewhere (grouped, say) is still found shared.
seen: [Option<HashSet<usize>>; 2],
/// For each side, what it keeps that the other doesn't.
bytes: [usize; 2],
}
impl Walk<'_> {
/// Two versions of a child list: the nodes in the same place on both sides (the same `Arc`, or
/// versions of the same object) are compared in place; the others (inserted, deleted, moved)
/// may still be shared from elsewhere.
fn list(&mut self, a: &[Arc<Node>], b: &[Arc<Node>]) {
let same = |x: &Arc<Node>, y: &Arc<Node>| Arc::ptr_eq(x, y) || x.id == y.id;
// In place from the start and from the end: an insertion or deletion shifts only what is
// between.
let head = a.iter().zip(b).take_while(|(x, y)| same(x, y)).count();
let (a_rest, b_rest) = (a.get(head..).unwrap_or_default(), b.get(head..).unwrap_or_default());
let tail = a_rest.iter().rev().zip(b_rest.iter().rev()).take_while(|(x, y)| same(x, y)).count();
for (x, y) in a.iter().zip(b).take(head).chain(a_rest.iter().rev().zip(b_rest.iter().rev()).take(tail)) {
self.pair(x, y);
}
let a_mid = a_rest.get(..a_rest.len().saturating_sub(tail)).unwrap_or_default();
let b_mid = b_rest.get(..b_rest.len().saturating_sub(tail)).unwrap_or_default();
for i in 0..a_mid.len().max(b_mid.len()) {
match (a_mid.get(i), b_mid.get(i)) {
(Some(x), Some(y)) if same(x, y) => self.pair(x, y),
(x, y) => {
x.into_iter().for_each(|x| self.alone(0, x));
y.into_iter().for_each(|y| self.alone(1, y));
}
}
}
}
/// Node `a` and `b` in the same place on the two sides: nothing when they are the same `Arc`,
/// both nodes and what under them isn't shared when they are versions of the same object.
fn pair(&mut self, a: &Arc<Node>, b: &Arc<Node>) {
if Arc::ptr_eq(a, b) {
return;
}
if a.id != b.id {
self.alone(0, a);
self.alone(1, b);
return;
}
self.bytes[0] += node_bytes(a, Some(b));
self.bytes[1] += node_bytes(b, Some(a));
match (a.children(), b.children()) {
(Some(x), Some(y)) => self.list(x, y),
(x, y) => {
x.into_iter().flatten().for_each(|c| self.alone(0, c));
y.into_iter().flatten().for_each(|c| self.alone(1, c));
}
}
match (&a.extras().mask, &b.extras().mask) {
(Some(x), Some(y)) => self.pair(&x.art, &y.art),
(x, y) => {
x.iter().for_each(|m| self.alone(0, &m.art));
y.iter().for_each(|m| self.alone(1, &m.art));
}
}
}
/// Node `n` of side `side` without a counterpart in place, and what under it the other side
/// doesn't hold.
fn alone(&mut self, side: usize, n: &Arc<Node>) {
if self.other_holds(side, n) {
return;
}
if let Some(b) = self.bytes.get_mut(side) {
*b += node_bytes(n, None);
}
for c in n.children().into_iter().flatten() {
self.alone(side, c);
}
if let Some(m) = &n.extras().mask {
self.alone(side, &m.art);
}
}
/// Whether the document across from `side` holds node `n` somewhere.
fn other_holds(&mut self, side: usize, n: &Arc<Node>) -> bool {
// Held only where it was found: not in the other document (which would hold it too). So
// new art inserted by the edit is told apart without looking.
if Arc::strong_count(n) == 1 {
return false;
}
let (Some(other), Some(seen)) = (self.docs.get(1 - side.min(1)), self.seen.get_mut(side)) else { return false };
let seen = seen.get_or_insert_with(|| {
fn add(n: &Arc<Node>, out: &mut HashSet<usize>) {
// A node already seen was entered already (a node shared within the document).
if !out.insert(Arc::as_ptr(n) as usize) {
return;
}
for c in n.children().into_iter().flatten() {
add(c, out);
}
if let Some(m) = &n.extras().mask {
add(&m.art, out);
}
}
let mut out = HashSet::new();
let roots = other.layers.iter().chain(other.symbols.iter().map(|s| &s.art)).chain(other.patterns.iter().flat_map(|p| &p.art));
for n in roots {
add(n, &mut out);
}
out
});
seen.contains(&(Arc::as_ptr(n) as usize))
}
}
/// One node's own heap: the node, and what it owns besides its children and mask art (counted
/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it.
fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
fn boxed<T>(b: &Option<Box<T>>) -> usize {
b.as_ref().map_or(0, |_| ALLOC + size_of::<T>())
}
let mut b = ALLOC + ARC + size_of::<Node>();
b += n.name.as_ref().map_or(0, String::capacity);
if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) {
b += ALLOC + ARC + appearance_heap(&n.appearance);
}
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children),
NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::<BlendSpec>(),
NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::<EnvelopeKind>() + size_of::<Affine>(),
NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND,
NodeKind::Text(t) => ALLOC + text_bytes(t),
NodeKind::Image(im) => ALLOC + size_of::<ImageObject>() + im.key.capacity(),
NodeKind::SymbolInstance { symbol, .. } => symbol.capacity(),
_ => OTHER_KIND,
};
b
}
fn path_bytes(p: &PathData) -> usize {
let anchors: usize =
p.subpaths.iter().map(|s| if s.anchors.capacity() == 0 { 0 } else { ALLOC + s.anchors.capacity() * size_of::<Anchor>() }).sum();
anchors + if p.subpaths.capacity() == 0 { 0 } else { ALLOC + p.subpaths.capacity() * size_of::<SubPath>() }
}
fn text_bytes(t: &TextObject) -> usize {
size_of::<TextObject>()
+ vec_bytes(&t.runs)
+ t.runs.iter().map(|r: &TextRun| r.text.capacity()).sum::<usize>()
+ t.paras.capacity() * size_of::<ParaStyle>()
+ vec_bytes(&t.wrap)
+ vec_bytes(&t.cached_baselines)
}
/// The heap an appearance owns (not the appearance itself).
fn appearance_heap(a: &Appearance) -> usize {
size_of::<Appearance>()
+ vec_bytes(&a.items)
+ a.items
.iter()
.map(|i| match i {
AppearanceItem::Fill(f) => paint_heap(&f.paint),
AppearanceItem::Stroke(s) => paint_heap(&s.paint),
})
.sum::<usize>()
+ vec_bytes(&a.effects)
+ a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::<usize>()
}
fn paint_heap(p: &Paint) -> usize {
match p {
Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity),
Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops),
Paint::Pattern { pattern, .. } => pattern.capacity(),
Paint::None => 0,
}
}
fn swatches_bytes(s: &[Swatch]) -> usize {
ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::<usize>()
}
/// A vector's buffer (not what its elements own).
fn vec_bytes<T>(v: &Vec<T>) -> usize {
if v.capacity() == 0 { 0 } else { ALLOC + v.capacity() * size_of::<T>() }
}
fn map_bytes(m: &serde_json::Map<String, Value>) -> usize {
m.iter().map(|(k, v)| ALLOC + 4 * size_of::<usize>() + k.capacity() + size_of::<Value>() + json_bytes(v)).sum()
}
/// The heap a JSON value owns.
fn json_bytes(v: &Value) -> usize {
match v {
Value::String(s) => s.capacity(),
Value::Array(a) => vec_bytes(a) + a.iter().map(json_bytes).sum::<usize>(),
Value::Object(m) => map_bytes(m),
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::shapes;
fn doc_with_rects(n: usize) -> Document {
let mut d = Document::new(1000.0, 1000.0);
let layer = d.layers[0].id;
for i in 0..n {
let id = d.alloc_id();
let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0));
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art())).unwrap();
}
d
}
#[test]
fn an_unchanged_copy_keeps_nothing_of_its_own() {
let d = doc_with_rects(10);
let copy = d.clone();
// Only the document itself and its own lists: no node, no shared resource.
assert!(d.retained_bytes(&copy) < 2048, "{}", d.retained_bytes(&copy));
assert_eq!(d.retained_bytes(&d), 0);
}
/// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared).
/// The process grew by about 775 bytes per moved rectangle when measured (#1244), about 490
/// once nodes kept their rarely set parts out of line.
#[test]
fn moving_rectangles_costs_their_nodes() {
let n = 1000;
let before = doc_with_rects(n);
let mut after = before.clone();
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
for id in ids {
let node = after.node_mut(id).unwrap();
if let NodeKind::Path { path, .. } = &mut node.kind {
for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) {
a.p.x += 5.0;
}
}
}
let per = before.retained_bytes(&after) / n;
assert!((400..1200).contains(&per), "{per} bytes per moved rectangle");
}
#[test]
fn a_grouped_object_is_found_shared() {
let before = doc_with_rects(100);
let mut after = before.clone();
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
// Put the rectangles in a group (the same Arcs, elsewhere).
let gid = after.alloc_id();
let layer = Arc::make_mut(&mut after.layers[0]);
let kids = std::mem::take(layer.children_mut().unwrap());
layer.children_mut().unwrap().push(Arc::new(Node::group(gid, kids)));
assert_eq!(ids.len(), 100);
let bytes = before.retained_bytes(&after);
assert!(bytes < 8192, "only the old layer and its child list: {bytes}");
}
/// One walk gives both ways, each what a walk the other way gives; an insertion and a deletion
/// cost the side that has the objects.
#[test]
fn both_ways_at_once() {
let before = doc_with_rects(100);
let mut after = before.clone();
let layer = after.layers[0].id;
for at in [0, 50, usize::MAX] {
let id = after.alloc_id();
let r = shapes::rectangle(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0));
after.insert(Some(layer), at, Node::path(id, r, Appearance::default_art())).unwrap();
}
let (kept, added) = before.retained_bytes_each(&after);
assert_eq!((kept, added), (before.retained_bytes(&after), after.retained_bytes(&before)));
assert_eq!(after.retained_bytes_each(&before), (added, kept));
// The old layer and its list are the old version's own; the new one holds three new nodes.
assert!(kept < 4096, "{kept}");
assert!(added > kept + 3 * 300, "{added} for three new rectangles");
}
#[test]
fn a_resource_counts_only_when_changed() {
let before = doc_with_rects(1);
let mut after = before.clone();
let same = before.retained_bytes(&after);
after.swatches.push(before.swatches[1].clone());
assert!(before.retained_bytes(&after) > same + 1000, "the old swatch list is the old version's own");
}
}
+653 -80
View File
@@ -394,7 +394,9 @@ pub struct ImageObject {
pub placement: crate::links::PlacementOptions,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
/// Written with its `type` first, then its fields (an internally tagged enum); read by
/// [`kind_de`], which takes the fields straight from the file when `type` comes first.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum NodeKind {
/// A top-level layer or sublayer.
@@ -427,8 +429,9 @@ pub enum NodeKind {
path: PathData,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
rule: FillRule,
/// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
live: Option<LiveShape>,
live: Option<Box<LiveShape>>,
/// This path acts as a clipping path inside a clip group.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
clipping: bool,
@@ -443,7 +446,8 @@ pub enum NodeKind {
rule: FillRule,
},
Text(Box<TextObject>),
Image(ImageObject),
/// Boxed, as the other large kinds: a node is as large as its largest kind.
Image(Box<ImageObject>),
SymbolInstance {
symbol: String,
xf: Affine,
@@ -453,12 +457,12 @@ pub enum NodeKind {
Blend {
children: Vec<Arc<Node>>,
#[serde(default)]
spec: BlendSpec,
spec: Box<BlendSpec>,
},
/// Live envelope distortion of `content`.
Envelope {
content: Vec<Arc<Node>>,
kind: EnvelopeKind,
kind: Box<EnvelopeKind>,
/// Envelope Options → Fidelity (0–100).
#[serde(default = "crate::live::default_fidelity")]
fidelity: f64,
@@ -472,7 +476,7 @@ pub enum NodeKind {
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
/// bounds in this frame, and a warp bends along it.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
frame: Affine,
frame: Box<Affine>,
},
/// Gradient mesh.
Mesh(GradientMesh),
@@ -491,72 +495,328 @@ fn one() -> f32 {
}
/// One object in the tree. Children are `Arc`s so documents share structure between undo states.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
///
/// What most objects have is kept inline; what few have is in [`NodeExtras`], allocated only for
/// the objects that have any of it: every undo step of an edit copies the nodes it changed, so a
/// small node is a cheap step. Files keep the flat field list either way: a node is written field
/// by field from where each lives, and read through a flat `NodeWire`.
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(from = "NodeWire")]
pub struct Node {
pub id: NodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
pub visible: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub locked: bool,
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
pub opacity: f32,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub blend: BlendMode,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub isolate: bool,
/// Knockout Group: whether a container's children knock each other out (see [`Knockout`]).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub knockout: Knockout,
/// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and
/// opacity mask scale how much of the objects below it in the group it knocks out.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub knockout_shape: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub appearance: Appearance,
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<Box<OpacityMask>>,
/// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows
/// them; `view` is its [`crate::TraceView`] id, absent for the tracing result).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<Box<serde_json::Value>>,
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrap: Option<crate::text::TextWrap>,
/// Graph object: the group's children are generated from this spec (Object → Graph).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// Index of the graph series this generated group belongs to. Axes, the legend and other
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_index: Option<u32>,
/// Editable Shaper composition; the original art is retained in its source child.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
pub appearance: crate::Shared<Appearance>,
pub kind: NodeKind,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
/// that style's look: editing its appearance or transparency breaks the link.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphic_style: Option<u32>,
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attrs: Option<Box<ObjectAttributes>>,
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
/// these options.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice: Option<Box<crate::SliceOptions>>,
/// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its
/// bounding box and handles stand at this angle. Transforms turn it with the object (see
/// [`crate::orient`]); Reset Bounding Box sets it back to 0.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bbox_angle: f64,
/// The rarely set parts, read with [`Node::extras`] and changed with [`Node::extras_mut`]
/// (public so that `Node { opacity: 1.0, ..node }` works).
pub extras: Extras,
}
/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without
/// them stay small ([`Node::extras`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NodeExtras {
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
pub mask: Option<Box<OpacityMask>>,
/// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows
/// them; `view` is its [`crate::TraceView`] id, absent for the tracing result).
pub trace: Option<Box<serde_json::Value>>,
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
pub wrap: Option<crate::text::TextWrap>,
/// Graph object: the group's children are generated from this spec (Object → Graph).
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// Index of the graph series this generated group belongs to. Axes, the legend and other
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
pub series_index: Option<u32>,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
/// that style's look: editing its appearance or transparency breaks the link.
pub graphic_style: Option<u32>,
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
pub attrs: Option<Box<ObjectAttributes>>,
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
/// these options.
pub slice: Option<Box<crate::SliceOptions>>,
/// Object › Perspective: the perspective grid plane the object is attached to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
static NO_EXTRAS: NodeExtras = NodeExtras {
mask: None,
trace: None,
wrap: None,
graph: None,
series_index: None,
shaper: None,
graphic_style: None,
attrs: None,
slice: None,
perspective: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
/// none.
#[derive(Clone, Debug, Default)]
pub struct Extras(Option<Box<NodeExtras>>);
impl Extras {
fn get(&self) -> &NodeExtras {
self.0.as_deref().unwrap_or(&NO_EXTRAS)
}
}
impl PartialEq for Extras {
fn eq(&self, other: &Self) -> bool {
match (&self.0, &other.0) {
(Some(a), Some(b)) => a == b,
_ => self.get() == other.get(),
}
}
}
impl Node {
/// The rarely set parts of this node (all unset when it has none).
#[inline]
pub fn extras(&self) -> &NodeExtras {
self.extras.get()
}
/// The rarely set parts of this node, to change: when the change is done (the guard dropped),
/// extras that were set are allocated and extras left all unset are let go. Setting one to
/// `None` on a node without extras allocates nothing.
#[inline]
pub fn extras_mut(&mut self) -> ExtrasMut<'_> {
ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() }
}
/// The rarely set parts of this node, if it has any: to change what is there in place.
#[inline]
pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> {
self.extras.0.as_deref_mut()
}
/// The live shape of a path object, if it is one.
pub fn live_shape(&self) -> Option<&LiveShape> {
match &self.kind {
NodeKind::Path { live, .. } => live.as_deref(),
_ => None,
}
}
/// The live shape of a path object, if it is one, to change.
pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> {
match &mut self.kind {
NodeKind::Path { live, .. } => live.as_deref_mut(),
_ => None,
}
}
/// This node with opacity mask `mask` (or none).
pub fn with_mask(mut self, mask: Option<Box<OpacityMask>>) -> Self {
self.extras_mut().mask = mask;
self
}
/// The extras, if this node has them allocated (for [`crate::mem`]'s estimate).
pub(crate) fn extras_box(&self) -> Option<&NodeExtras> {
self.extras.0.as_deref()
}
}
/// [`Node::extras_mut`]: the node's extras while they change.
pub struct ExtrasMut<'a> {
slot: &'a mut Option<Box<NodeExtras>>,
/// Stands for the extras of a node without any, boxed into it when dropped if one was set.
local: NodeExtras,
}
impl std::ops::Deref for ExtrasMut<'_> {
type Target = NodeExtras;
fn deref(&self) -> &NodeExtras {
self.slot.as_deref().unwrap_or(&self.local)
}
}
impl std::ops::DerefMut for ExtrasMut<'_> {
fn deref_mut(&mut self) -> &mut NodeExtras {
match self.slot.as_deref_mut() {
Some(e) => e,
None => &mut self.local,
}
}
}
impl Drop for ExtrasMut<'_> {
fn drop(&mut self) {
match self.slot.as_deref() {
Some(e) if *e == NO_EXTRAS => *self.slot = None,
Some(_) => {}
None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))),
None => {}
}
}
}
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
impl Serialize for Node {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use crate::skip::{is_default, is_one, is_true};
use serde::ser::SerializeStruct;
let e = self.extras();
// As the derive did: the fields written, then each field written or skipped in order.
let skips = [
false,
self.name.is_none(),
is_true(&self.visible),
is_default(&self.locked),
is_one(&self.opacity),
is_default(&self.blend),
is_default(&self.isolate),
is_default(&self.knockout),
is_default(&self.knockout_shape),
is_default(&self.appearance),
e.mask.is_none(),
e.trace.is_none(),
e.wrap.is_none(),
e.graph.is_none(),
e.series_index.is_none(),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
e.attrs.is_none(),
e.slice.is_none(),
is_default(&self.bbox_angle),
e.perspective.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
macro_rules! field {
($name:literal, $v:expr) => {
if skip.next().unwrap_or(false) {
st.skip_field($name)?;
} else {
st.serialize_field($name, $v)?;
}
};
}
field!("id", &self.id);
field!("name", &self.name);
field!("visible", &self.visible);
field!("locked", &self.locked);
field!("opacity", &self.opacity);
field!("blend", &self.blend);
field!("isolate", &self.isolate);
field!("knockout", &self.knockout);
field!("knockout_shape", &self.knockout_shape);
field!("appearance", &self.appearance);
field!("mask", &e.mask);
field!("trace", &e.trace);
field!("wrap", &e.wrap);
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
field!("attrs", &e.attrs);
field!("slice", &e.slice);
field!("bbox_angle", &self.bbox_angle);
field!("perspective", &e.perspective);
st.end()
}
}
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
/// (and its extras, allocated only when one is set) without a copy of what they own.
#[derive(Deserialize)]
#[serde(rename = "Node")]
struct NodeWire {
id: NodeId,
#[serde(default)]
name: Option<String>,
#[serde(default = "yes")]
visible: bool,
#[serde(default)]
locked: bool,
#[serde(default = "one")]
opacity: f32,
#[serde(default)]
blend: BlendMode,
#[serde(default)]
isolate: bool,
#[serde(default)]
knockout: Knockout,
#[serde(default)]
knockout_shape: bool,
#[serde(default)]
appearance: crate::Shared<Appearance>,
#[serde(default)]
mask: Option<Box<OpacityMask>>,
#[serde(default)]
trace: Option<Box<serde_json::Value>>,
#[serde(default)]
wrap: Option<crate::text::TextWrap>,
#[serde(default)]
graph: Option<Box<crate::graph::GraphSpec>>,
#[serde(default)]
series_index: Option<u32>,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
graphic_style: Option<u32>,
#[serde(default)]
attrs: Option<Box<ObjectAttributes>>,
#[serde(default)]
slice: Option<Box<crate::SliceOptions>>,
#[serde(default)]
bbox_angle: f64,
#[serde(default)]
perspective: Option<Box<crate::PerspectiveAttachment>>,
}
impl From<NodeWire> for Node {
fn from(w: NodeWire) -> Self {
let extras = NodeExtras {
mask: w.mask,
trace: w.trace,
wrap: w.wrap,
graph: w.graph,
series_index: w.series_index,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
slice: w.slice,
perspective: w.perspective,
};
Node {
id: w.id,
name: w.name,
visible: w.visible,
locked: w.locked,
opacity: w.opacity,
blend: w.blend,
isolate: w.isolate,
knockout: w.knockout,
knockout_shape: w.knockout_shape,
appearance: w.appearance,
kind: w.kind,
bbox_angle: w.bbox_angle,
extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))),
}
}
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OpacityMask {
@@ -594,24 +854,15 @@ impl Node {
isolate: false,
knockout: Knockout::Neutral,
knockout_shape: false,
appearance: Appearance::default(),
mask: None,
trace: None,
wrap: None,
graph: None,
series_index: None,
shaper: None,
appearance: Appearance::default().into(),
kind,
graphic_style: None,
attrs: None,
slice: None,
bbox_angle: 0.0,
perspective: None,
extras: Extras::default(),
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false });
n.appearance = appearance;
n.appearance = appearance.into();
n
}
pub fn group(id: NodeId, children: Vec<Arc<Node>>) -> Self {
@@ -691,7 +942,7 @@ impl Node {
&& !self.isolate
&& self.knockout == Knockout::Neutral
&& !self.knockout_shape
&& self.mask.is_none()
&& self.extras().mask.is_none()
}
/// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str {
@@ -772,7 +1023,7 @@ impl Node {
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.skip(usize::from(self.shaper.is_some()))
.skip(usize::from(self.extras().shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
NodeKind::Text(t) => self.projected(t.bounds()),
@@ -782,7 +1033,7 @@ impl Node {
let b = crate::live::nodes_bounds(children);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
}
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
@@ -806,7 +1057,7 @@ impl Node {
let o = self.appearance.outset();
children
.iter()
.skip(usize::from(self.shaper.is_some()))
.skip(usize::from(self.extras().shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
.map(|b| b.inflate(o, o))
}
@@ -854,7 +1105,11 @@ impl Node {
self.appearance.scale_effects(k, f);
}
}
self.appearance.transform_gradients(a);
// Only through `DerefMut` when there is something to change: the appearance is shared
// copy-on-write (`Shared`), and a move must not copy what it leaves as it is.
if self.appearance.gradients().next().is_some() {
self.appearance.transform_gradients(a);
}
if sc.patterns {
crate::pattern::transform_pattern_paints(self, a);
}
@@ -899,8 +1154,8 @@ impl Node {
for c in content.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
*frame = crate::live::envelope_frame(a * *frame);
match kind {
**frame = crate::live::envelope_frame(a * **frame);
match &mut **kind {
EnvelopeKind::Mesh { points, handles, .. } => {
for p in points.iter_mut() {
*p = a * *p;
@@ -919,7 +1174,7 @@ impl Node {
NodeKind::Mesh(m) => m.transform(a),
NodeKind::Repeat(r) => r.transform(a, *sc),
}
if let Some(m) = &mut self.mask
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
&& m.linked
{
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
@@ -1035,7 +1290,7 @@ impl Node {
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) {
for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) {
c.push_clip_shapes(text, out);
}
}
@@ -1320,25 +1575,25 @@ impl ImageMap {
impl Node {
/// The URL this object links to, if any.
pub fn url(&self) -> Option<&str> {
self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
}
/// Whether the canvas shows this object's centre point when it is selected: as set in the
/// Attributes panel, else [`Self::shows_center_by_default`].
pub fn shows_center(&self) -> bool {
self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
}
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
pub fn shows_center_by_default(&self) -> bool {
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. }))
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. }))
}
/// Change this object's attributes with `f`; all-default attributes are dropped.
pub fn edit_attrs<R>(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R {
let mut a = self.attrs.take().unwrap_or_default();
let mut a = self.extras_mut().attrs.take().unwrap_or_default();
let r = f(&mut a);
self.attrs = (*a != ObjectAttributes::default()).then_some(a);
self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a);
r
}
}
@@ -1354,7 +1609,7 @@ impl Node {
self.visible
&& (self.opacity < 1.0
|| self.blend != BlendMode::Normal
|| self.mask.as_ref().is_some_and(|m| !m.disabled)
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|| effects(&self.appearance.effects)
|| self
.appearance
@@ -1381,10 +1636,281 @@ impl Node {
}
}
/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every
/// object inside a group, again at each level of nesting) before reading it, since `type` may come
/// anywhere among its fields. Files have it first (it is written first): then the kind is read
/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the
/// fields before `type` are buffered, and only those.
mod kind_de {
use std::fmt;
use std::sync::Arc;
use serde::de::value::{MapAccessDeserializer, StringDeserializer};
use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor};
use serde::{Deserialize, forward_to_deserialize_any};
use vectorcraft_geom::{Affine, FillRule, PathData};
use super::{LayerColor, LiveShape, Node, NodeKind, yes};
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
use crate::pattern::RepeatSpec;
use crate::text::TextObject;
/// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes
/// note (the compiler checks that every field is here), read externally tagged: the kind from
/// `type`, its fields from the rest of the object ([`Tagged`]).
#[derive(Deserialize)]
#[serde(remote = "NodeKind", rename_all = "lowercase")]
enum KindDef {
Layer {
color: LayerColor,
#[serde(default)]
template: bool,
#[serde(default = "yes")]
printable: bool,
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
#[serde(default = "yes")]
preview: bool,
#[serde(default)]
dim_images: Option<u8>,
},
Group {
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
},
Path {
path: PathData,
#[serde(default)]
rule: FillRule,
#[serde(default)]
live: Option<Box<LiveShape>>,
#[serde(default)]
clipping: bool,
#[serde(default)]
guide: bool,
},
Compound {
children: Vec<Arc<Node>>,
#[serde(default)]
rule: FillRule,
},
Text(Box<TextObject>),
Image(Box<super::ImageObject>),
SymbolInstance {
symbol: String,
xf: Affine,
},
Blend {
children: Vec<Arc<Node>>,
#[serde(default)]
spec: Box<BlendSpec>,
},
Envelope {
content: Vec<Arc<Node>>,
kind: Box<EnvelopeKind>,
#[serde(default = "crate::live::default_fidelity")]
fidelity: f64,
#[serde(default)]
editing: bool,
#[serde(default)]
options: crate::live::EnvelopeOptions,
#[serde(default)]
frame: Box<Affine>,
},
Mesh(GradientMesh),
Repeat(RepeatSpec),
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
}
impl<'de> Deserialize<'de> for NodeKind {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_map(KindVisitor)
}
}
struct KindVisitor;
impl<'de> Visitor<'de> for KindVisitor {
type Value = NodeKind;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an object kind (a map with `type`)")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<NodeKind, A::Error> {
let mut before = vec![];
while let Some(key) = map.next_key::<String>()? {
if key == "type" {
let tag: String = map.next_value()?;
return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } });
}
before.push((key, map.next_value::<serde_json::Value>()?));
}
Err(de::Error::missing_field("type"))
}
}
/// The fields read before `type` (buffered), then the rest of the object.
struct Buffered<A> {
before: std::vec::IntoIter<(String, serde_json::Value)>,
value: Option<serde_json::Value>,
map: A,
}
impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered<A> {
type Error = A::Error;
fn next_key_seed<K: DeserializeSeed<'de>>(&mut self, seed: K) -> Result<Option<K::Value>, A::Error> {
match self.before.next() {
Some((key, value)) => {
self.value = Some(value);
seed.deserialize(StringDeserializer::<A::Error>::new(key)).map(Some)
}
None => self.map.next_key_seed(seed),
}
}
fn next_value_seed<V: DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, A::Error> {
match self.value.take() {
Some(value) => seed.deserialize(value).map_err(de::Error::custom),
None => self.map.next_value_seed(seed),
}
}
}
/// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`.
struct Tagged<A> {
tag: String,
map: A,
}
impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged<A> {
type Error = A::Error;
fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, A::Error> {
visitor.visit_enum(self)
}
forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf
option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum
identifier ignored_any
}
}
impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged<A> {
type Error = A::Error;
type Variant = Fields<A>;
fn variant_seed<V: DeserializeSeed<'de>>(self, seed: V) -> Result<(V::Value, Fields<A>), A::Error> {
let kind = seed.deserialize(self.tag.into_deserializer())?;
Ok((kind, Fields(self.map)))
}
}
/// A kind's fields: the rest of the object.
struct Fields<A>(A);
impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields<A> {
type Error = A::Error;
fn unit_variant(self) -> Result<(), A::Error> {
Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields"))
}
fn newtype_variant_seed<T: DeserializeSeed<'de>>(self, seed: T) -> Result<T::Value, A::Error> {
seed.deserialize(MapAccessDeserializer::new(self.0))
}
fn tuple_variant<V: Visitor<'de>>(self, _: usize, _: V) -> Result<V::Value, A::Error> {
Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields"))
}
fn struct_variant<V: Visitor<'de>>(self, _: &'static [&'static str], visitor: V) -> Result<V::Value, A::Error> {
visitor.visit_map(self.0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A node writes its rarely set parts among the others, in the file's field order, and reads
/// them back into its extras; a node without any keeps none.
#[test]
fn extras_are_written_flat_in_order() {
let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
n.name = Some("n".into());
n.knockout_shape = true;
n.bbox_angle = 30.0;
{
let mut e = n.extras_mut();
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
e.series_index = Some(2);
e.graphic_style = Some(3);
e.attrs = Some(Box::default());
e.slice = Some(Box::default());
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
impl<'de> Deserialize<'de> for Keys {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Keys;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Keys, A::Error> {
let mut out = vec![];
while let Some(k) = a.next_key::<String>()? {
a.next_value::<serde::de::IgnoredAny>()?;
out.push(k);
}
Ok(Keys(out))
}
}
d.deserialize_map(V)
}
}
let text = serde_json::to_string(&n).unwrap();
let keys = serde_json::from_str::<Keys>(&text).unwrap().0;
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let order = [
"id",
"name",
"knockout_shape",
"appearance",
"mask",
"trace",
"series_index",
"kind",
"graphic_style",
"attrs",
"slice",
"bbox_angle",
"perspective",
];
assert_eq!(keys, order);
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
assert!(back.extras_box().is_none());
// Clearing the last extra lets them go; clearing one a node doesn't have allocates none.
n.extras_mut().mask = None;
assert!(n.extras_box().is_some());
*n.extras_mut() = NodeExtras::default();
assert!(n.extras_box().is_none());
n.extras_mut().mask = None;
assert!(n.extras_box().is_none());
assert_eq!(n.extras(), &NodeExtras::default());
}
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) };
@@ -1450,13 +1976,14 @@ mod tests {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
*slot = Some(Box::new(live));
}
n
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") };
let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") };
let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
@@ -1591,7 +2118,7 @@ mod tests {
|n| n.knockout = Knockout::On,
|n| n.knockout = Knockout::Off,
|n| n.knockout_shape = true,
|n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
|n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
];
for tweak in tweaks {
let mut n = p();
@@ -1647,4 +2174,50 @@ mod tests {
p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round;
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
}
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
fn nested_group() -> Node {
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() };
let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true });
let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd });
let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)];
Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false })
}
#[test]
fn kinds_read_back_with_type_first_or_anywhere() {
let n = nested_group();
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}");
assert_eq!(serde_json::from_str::<Node>(&json).unwrap(), n);
// Through a JSON value, whose keys are sorted: `type` comes after `children`.
let v = serde_json::to_value(&n).unwrap();
assert_eq!(serde_json::from_value::<Node>(v.clone()).unwrap(), n);
assert_eq!(serde_json::from_str::<Node>(&v.to_string()).unwrap(), n);
// `type` last, in the middle, and keys a newer version added (ignored, as before).
let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#;
assert_eq!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Group { children: vec![], clip: true });
let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#;
assert_eq!(
serde_json::from_str::<NodeKind>(kind).unwrap(),
NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) }
);
let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#;
assert!(matches!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3));
// Defaults as before.
let NodeKind::Layer { printable, preview, template, .. } =
serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap()
else {
panic!("a layer")
};
assert!(printable && preview && !template);
}
#[test]
fn bad_kinds_are_errors() {
for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] {
assert!(serde_json::from_str::<NodeKind>(bad).is_err(), "{bad}");
}
}
}
+8
View File
@@ -343,6 +343,14 @@ pub fn uses_pattern(n: &Node, name: Option<&str>) -> bool {
/// Apply `m` to the pattern placement of every pattern paint of `n` (Transform Patterns).
pub fn transform_pattern_paints(n: &mut Node, m: Affine) {
// Without pattern paints there is nothing to change: don't copy the shared appearance.
let patterned = |it: &AppearanceItem| {
matches!(it, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Pattern { .. }))
|| matches!(it, AppearanceItem::Stroke(s) if matches!(s.paint, Paint::Pattern { .. }))
};
if !n.appearance.items.iter().any(patterned) {
return;
}
for it in n.appearance.items.iter_mut() {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
+2 -2
View File
@@ -46,7 +46,7 @@ impl Node {
/// The projective map type or a symbol instance in perspective is drawn through (not while
/// Edit Text shows it flat).
pub fn projection(&self) -> Option<Homography> {
let a = self.perspective.as_deref().filter(|a| !a.editing)?;
let a = self.extras().perspective.as_deref().filter(|a| !a.editing)?;
matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten()
}
@@ -60,7 +60,7 @@ impl Node {
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
if let Some(p) = self.perspective.as_deref_mut()
if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut())
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
{
p.projection = Some(h.to_array());
+28 -10
View File
@@ -119,7 +119,7 @@ fn prefix(key: &str) -> String {
/// `n` and every descendant, opacity-mask art included.
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
if let Some(m) = &n.mask {
if let Some(m) = &n.extras().mask {
walk_all(&m.art, f);
}
for c in n.children().into_iter().flatten() {
@@ -129,11 +129,19 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
/// The roots of `d`'s art: layers, symbol definitions and pattern swatches.
fn roots(d: &Document) -> impl Iterator<Item = &Arc<Node>> {
d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| &p.art))
d.layers
.iter()
.chain(d.symbols.iter().map(|s| &s.art))
.chain(d.graph_designs.iter().map(|g| &g.art))
.chain(d.patterns.iter().flat_map(|p| &p.art))
}
fn roots_mut(d: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
d.layers
.iter_mut()
.chain(d.symbols.iter_mut().map(|s| &mut s.art))
.chain(d.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
}
impl Document {
@@ -176,7 +184,7 @@ impl Document {
}
// The art shown may hold placed documents too.
let n = Arc::make_mut(n);
if let Some(m) = &mut n.mask {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
update(&mut m.art);
}
for c in n.children_mut().into_iter().flatten() {
@@ -202,11 +210,11 @@ impl Document {
&& keys.contains(&p.key)
{
if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) {
n.kind = NodeKind::Image(im);
n.kind = NodeKind::Image(Box::new(im));
}
return;
}
if let Some(m) = &mut n.mask {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
update(&mut m.art, keys, images);
}
for c in n.children_mut().into_iter().flatten() {
@@ -398,7 +406,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocu
doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None });
let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64);
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() };
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(im)))], clip: false });
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(Box::new(im))))], clip: false });
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
cache().insert(e.clone());
Some(e)
@@ -424,7 +432,7 @@ fn art_id() -> crate::NodeId {
/// Visit `n` and every descendant (opacity-mask art included) mutably.
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
f(n);
if let Some(m) = &mut n.mask {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
each_mut(Arc::make_mut(&mut m.art), f);
}
for c in n.children_mut().into_iter().flatten() {
@@ -495,7 +503,10 @@ impl Document {
let prefix = prefix(&p.key);
add_resources(&mut d, &e.doc, &prefix);
// The placed documents in that art, under their names here.
let inner = std::iter::once(&e.art).chain(e.doc.symbols.iter().map(|s| &s.art)).chain(e.doc.patterns.iter().flat_map(|p| &p.art));
let inner = std::iter::once(&e.art)
.chain(e.doc.symbols.iter().map(|s| &s.art))
.chain(e.doc.graph_designs.iter().map(|g| &g.art))
.chain(e.doc.patterns.iter().flat_map(|p| &p.art));
for root in inner {
walk_all(root, &mut |n| {
if let NodeKind::PlacedDocument(q) = &n.kind {
@@ -579,7 +590,14 @@ mod tests {
d.layers.clear();
let art = Node::new(
NodeId(50),
NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
NodeKind::Image(Box::new(ImageObject {
key: "pic".into(),
width: 1,
height: 1,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
})),
);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
+45 -144
View File
@@ -1,6 +1,6 @@
//! Selection state (objects and, for direct selection, individual anchors; ruler guides).
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::{Deserialize, Serialize};
@@ -8,14 +8,21 @@ use crate::{Document, NodeId};
/// (subpath index, anchor index) inside a path.
pub type AnchorRef = (usize, usize);
/// (subpath index, segment index) inside a path.
pub type SegmentRef = (usize, usize);
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Selection {
/// Selected objects in selection order.
pub objects: Vec<NodeId>,
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
/// Direct-selected anchors, or a selected segment's endpoints used for movement. A path in
/// `objects` with no entry here is fully selected; `segments` distinguishes segment picks.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
/// Explicitly selected segments. Their endpoints in `anchors` move with them, but are not
/// selected anchor points: Delete cuts connections and their anchor squares stay hollow.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub segments: BTreeMap<NodeId, BTreeSet<SegmentRef>>,
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
/// it when every selected object is the key or lies inside it, as when one object is left
/// selected or when the key is a group selected with only its own members.
@@ -55,6 +62,7 @@ impl Selection {
pub fn clear(&mut self) {
self.objects.clear();
self.anchors.clear();
self.segments.clear();
self.key = None;
self.target = None;
self.slices.clear();
@@ -62,9 +70,9 @@ impl Selection {
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
for id in ids {
self.add(id);
}
// What `add` per id does after a clear, without its linear duplicate check per id.
let mut seen = std::collections::HashSet::new();
self.objects.extend(ids.into_iter().filter(|id| seen.insert(*id)));
}
pub fn add(&mut self, id: NodeId) {
self.target = None;
@@ -78,6 +86,7 @@ impl Selection {
self.slices.clear();
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
self.segments.remove(&id);
if self.key == Some(id) {
self.key = None;
}
@@ -116,12 +125,26 @@ impl Selection {
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
self.anchors.get(&id)
}
/// Which direction handles belong to selected segments; both for an anchor selection.
pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) {
let Some(segments) = self.segments.get(&id) else { return (true, true) };
let previous = ai.checked_sub(1).or_else(|| sp.closed.then_some(sp.segment_count()).and_then(|n| n.checked_sub(1)));
(previous.is_some_and(|i| segments.contains(&(si, i))), segments.contains(&(si, ai)))
}
/// Drop ids that no longer exist or are no longer editable.
pub fn prune(&mut self, doc: &Document) {
self.objects.retain(|id| doc.node(*id).is_some());
self.anchors.retain(|id, _| doc.node(*id).is_some());
// Every id this looks up, found in one walk of the tree.
let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target);
let found = doc.index_paths(ids);
self.objects.retain(|id| found.contains_key(id));
self.anchors.retain(|id, _| found.contains_key(id));
self.segments.retain(|id, refs| {
let Some(path) = found.get(id).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()) else { return false };
refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count()));
!refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id)
});
// The key object stays while another selected object lies outside it.
let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| {
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
let mut inside = BTreeSet::new();
n.walk(&mut |c| {
inside.insert(c.id);
@@ -131,7 +154,7 @@ impl Selection {
if !keep {
self.key = None;
}
if self.target.is_some_and(|t| doc.node(t).is_none()) {
if self.target.is_some_and(|t| !found.contains_key(&t)) {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));
@@ -142,9 +165,17 @@ impl Selection {
/// around them (and so the key object when it is one): hidden or locked art can't stay
/// selected.
pub fn deselect_uneditable(&mut self, doc: &Document) {
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
for o in out {
self.remove(o);
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
if uneditable.is_empty() {
return;
}
self.target = None;
self.slices.clear();
self.objects.retain(|o| !uneditable.contains(o));
self.anchors.retain(|id, _| !uneditable.contains(id));
self.segments.retain(|id, _| !uneditable.contains(id));
if self.key.is_some_and(|k| uneditable.contains(&k)) {
self.key = None;
}
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
@@ -185,134 +216,4 @@ impl Selection {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_ops() {
let mut s = Selection::default();
s.add(NodeId(1));
s.add(NodeId(1));
s.toggle(NodeId(2));
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
s.toggle(NodeId(1));
assert_eq!(s.objects, vec![NodeId(2)]);
s.clear();
assert!(s.is_empty());
}
#[test]
fn guides_are_selected_on_their_own() {
let mut s = Selection::default();
s.add(NodeId(1));
s.set_guides([2, 0, 2]);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
assert!(s.has_objects_or_guides() && s.is_empty());
// Shift-adding an object keeps them; a new selection doesn't.
s.add(NodeId(1));
assert_eq!(s.guides, vec![2, 0]);
s.set([NodeId(1)]);
assert!(s.guides.is_empty());
// Toggled, a guide joins (or leaves) the selected objects.
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
// Pruning drops guides the document no longer has.
let mut d = Document::new(100.0, 100.0);
d.guides.push(crate::Guide::new(true, 10.0));
s.set_guides([0, 1]);
s.prune(&d);
assert_eq!(s.guides, vec![0]);
}
#[test]
fn guides_show_and_lock_with_their_layer() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
assert!(d.guide_shown(&g) && d.guide_editable(&g));
d.node_mut(layer).unwrap().locked = true;
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
d.node_mut(layer).unwrap().visible = false;
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
// On no layer, or a layer that's gone: always there.
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
d.guides.push(g);
assert_eq!(d.guides_on(layer).count(), 1);
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
d.node_mut(layer).unwrap().visible = true;
d.node_mut(layer).unwrap().locked = false;
d.guides.push(crate::Guide::new(false, 5.0));
d.guides.push(gone);
assert_eq!(d.adopt_guides(), 2);
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
assert_eq!(d.adopt_guides(), 0, "once");
// A guide saved before guides had layers reads as one on no layer.
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
assert_eq!(old.layer, None);
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
s.set([NodeId(2), NodeId(3)]);
s.target = Some(NodeId(1));
assert_eq!(s.subjects(), &[NodeId(1)]);
s.add(NodeId(4));
assert_eq!(s.target, None);
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
s.target = Some(NodeId(1));
s.remove(NodeId(2));
assert_eq!(s.target, None);
s.target = Some(NodeId(1));
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
s.set([NodeId(1), NodeId(2)]);
s.key = Some(NodeId(2));
s.remove(NodeId(1));
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
s.remove(NodeId(2));
assert_eq!(s.key, None, "the key object itself left");
// Pruning drops objects the document no longer has and clears the key once no other
// selected object lies outside it.
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for _ in 0..4 {
let id = d.alloc_id();
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
}
s.set(ids.iter().copied());
s.key = Some(ids[3]);
d.remove(ids[0]).unwrap();
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
s.toggle(ids[1]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
s.toggle(ids[2]);
s.prune(&d);
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
// A group key selected with only its own member is cleared too; another selected object
// outside the group keeps it.
let member = d.alloc_id();
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
s.set([member, ids[3]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, None, "a group and its own member");
s.set([member, ids[3], ids[2]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
}
}
mod tests;
+219
View File
@@ -0,0 +1,219 @@
use std::sync::Arc;
use super::*;
use crate::Node;
#[test]
fn basic_ops() {
let mut s = Selection::default();
s.add(NodeId(1));
s.add(NodeId(1));
s.toggle(NodeId(2));
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
s.toggle(NodeId(1));
assert_eq!(s.objects, vec![NodeId(2)]);
s.clear();
assert!(s.is_empty());
}
#[test]
fn guides_are_selected_on_their_own() {
let mut s = Selection::default();
s.add(NodeId(1));
s.set_guides([2, 0, 2]);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
assert!(s.has_objects_or_guides() && s.is_empty());
// Shift-adding an object keeps them; a new selection doesn't.
s.add(NodeId(1));
assert_eq!(s.guides, vec![2, 0]);
s.set([NodeId(1)]);
assert!(s.guides.is_empty());
// Toggled, a guide joins (or leaves) the selected objects.
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
// Pruning drops guides the document no longer has.
let mut d = Document::new(100.0, 100.0);
d.guides.push(crate::Guide::new(true, 10.0));
s.set_guides([0, 1]);
s.prune(&d);
assert_eq!(s.guides, vec![0]);
}
#[test]
fn guides_show_and_lock_with_their_layer() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
assert!(d.guide_shown(&g) && d.guide_editable(&g));
d.node_mut(layer).unwrap().locked = true;
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
d.node_mut(layer).unwrap().visible = false;
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
// On no layer, or a layer that's gone: always there.
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
d.guides.push(g);
assert_eq!(d.guides_on(layer).count(), 1);
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
d.node_mut(layer).unwrap().visible = true;
d.node_mut(layer).unwrap().locked = false;
d.guides.push(crate::Guide::new(false, 5.0));
d.guides.push(gone);
assert_eq!(d.adopt_guides(), 2);
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
assert_eq!(d.adopt_guides(), 0, "once");
// A guide saved before guides had layers reads as one on no layer.
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
assert_eq!(old.layer, None);
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
s.set([NodeId(2), NodeId(3)]);
s.target = Some(NodeId(1));
assert_eq!(s.subjects(), &[NodeId(1)]);
s.add(NodeId(4));
assert_eq!(s.target, None);
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
s.target = Some(NodeId(1));
s.remove(NodeId(2));
assert_eq!(s.target, None);
s.target = Some(NodeId(1));
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
s.set([NodeId(1), NodeId(2)]);
s.key = Some(NodeId(2));
s.remove(NodeId(1));
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
s.remove(NodeId(2));
assert_eq!(s.key, None, "the key object itself left");
// Pruning drops objects the document no longer has and clears the key once no other
// selected object lies outside it.
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for _ in 0..4 {
let id = d.alloc_id();
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
}
s.set(ids.iter().copied());
s.key = Some(ids[3]);
d.remove(ids[0]).unwrap();
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
s.toggle(ids[1]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
s.toggle(ids[2]);
s.prune(&d);
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
// A group key selected with only its own member is cleared too; another selected object
// outside the group keeps it.
let member = d.alloc_id();
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
s.set([member, ids[3]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, None, "a group and its own member");
s.set([member, ids[3], ids[2]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
}
fn below(r: &mut u64, n: u64) -> u64 {
*r ^= *r << 13;
*r ^= *r >> 7;
*r ^= *r << 17;
*r % n.max(1)
}
fn random_group(d: &mut Document, r: &mut u64, depth: u64, ids: &mut Vec<NodeId>) -> Arc<Node> {
let id = d.alloc_id();
ids.push(id);
let count = if depth == 0 { 0 } else { 1 + below(r, 4) };
let children = (0..count)
.map(|_| {
let depth = below(r, depth);
random_group(d, r, depth, ids)
})
.collect();
let mut group = Node::group(id, children);
group.locked = below(r, 6) == 0;
group.visible = below(r, 6) != 0;
Arc::new(group)
}
fn random_document(r: &mut u64) -> (Document, Vec<NodeId>) {
let mut d = Document::new(100.0, 100.0);
let mut ids = vec![];
for _ in 0..3 {
let mut layer = Node::layer(d.alloc_id(), "L", crate::LayerColor::Preset(0));
let children = (0..1 + below(r, 4)).map(|_| random_group(&mut d, r, 3, &mut ids)).collect();
if let Some(c) = layer.children_mut() {
*c = children;
}
layer.locked = below(r, 6) == 0;
layer.visible = below(r, 6) != 0;
d.layers.push(Arc::new(layer));
}
(d, ids)
}
fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection {
let mut s = Selection::default();
let missing = (below(r, 2) == 0).then_some(NodeId(u64::MAX));
s.set(ids.iter().copied().filter(|_| below(r, 2) == 0).chain(missing));
let k = below(r, s.objects.len() as u64) as usize;
s.objects.rotate_left(k);
for &o in &s.objects {
match below(r, 3) {
0 => {
s.anchors.insert(o, BTreeSet::from([(0, 0)]));
}
1 => {
s.anchors.insert(o, BTreeSet::from([(0, 1)]));
s.segments.insert(o, BTreeSet::from([(0, 0)]));
}
_ => {}
}
}
s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied();
s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0);
s.slices = ids.iter().copied().take(below(r, 3) as usize).collect();
s.guides = vec![1, 0];
s
}
#[test]
fn deselect_uneditable_matches_removing_one_at_a_time() {
let (mut unchanged, mut partial) = (0, 0);
for seed in 1..400u64 {
let mut r = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1;
let (d, mut ids) = random_document(&mut r);
if seed % 4 == 0 {
ids.retain(|id| d.is_editable(*id));
}
let s = random_selection(&mut r, &ids);
let mut expected = s.clone();
for o in s.objects.iter().copied().filter(|o| !d.is_editable(*o)) {
expected.remove(o);
}
let mut actual = s.clone();
actual.deselect_uneditable(&d);
assert_eq!(actual, expected, "seed {seed}");
unchanged += usize::from(actual == s);
partial += usize::from(!actual.objects.is_empty() && actual.objects.len() < s.objects.len());
}
assert!(unchanged > 10 && partial > 100, "{unchanged} {partial}");
}
+174
View File
@@ -0,0 +1,174 @@
//! [`Shared`]: a value shared between copies of a node until one of them changes it.
use std::sync::Arc;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A copy-on-write value: cloning shares it, and the first change through `DerefMut` makes the
/// changed copy its own (`Arc::make_mut`). A node moved or recoloured keeps sharing what it didn't
/// change with the version the undo history holds. Reads, writes, equality and the saved form are
/// exactly those of the value itself.
#[derive(Clone, Default)]
pub struct Shared<T>(Arc<T>);
impl<T> Shared<T> {
pub fn new(v: T) -> Self {
Shared(Arc::new(v))
}
/// Whether `a` and `b` share one value (an unchanged copy).
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
Arc::ptr_eq(&a.0, &b.0)
}
}
impl<T> std::ops::Deref for Shared<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T: Clone> std::ops::DerefMut for Shared<T> {
fn deref_mut(&mut self) -> &mut T {
Arc::make_mut(&mut self.0)
}
}
impl<T: Clone> Shared<T> {
/// The value, taken out (copied only when another copy still shares it).
pub fn into_inner(self) -> T {
Arc::unwrap_or_clone(self.0)
}
}
/// `for x in std::mem::take(&mut doc.swatches)` takes the value, as it would the value itself.
impl<T: Clone + IntoIterator> IntoIterator for Shared<T> {
type Item = T::Item;
type IntoIter = T::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.into_inner().into_iter()
}
}
/// `for x in &doc.swatches` iterates the shared value, as it would the value itself.
impl<'a, T> IntoIterator for &'a Shared<T>
where
&'a T: IntoIterator,
{
type Item = <&'a T as IntoIterator>::Item;
type IntoIter = <&'a T as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
(&*self.0).into_iter()
}
}
/// `for x in &mut doc.swatches` makes the value this copy's own first, as any change does.
impl<'a, T: Clone> IntoIterator for &'a mut Shared<T>
where
&'a mut T: IntoIterator,
{
type Item = <&'a mut T as IntoIterator>::Item;
type IntoIter = <&'a mut T as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
Arc::make_mut(&mut self.0).into_iter()
}
}
impl<T> From<T> for Shared<T> {
fn from(v: T) -> Self {
Shared::new(v)
}
}
impl<T: PartialEq> PartialEq for Shared<T> {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0) || *self.0 == *other.0
}
}
/// A shared value equals the plain value it holds (`node.appearance == style.appearance`).
impl<T: PartialEq> PartialEq<T> for Shared<T> {
fn eq(&self, other: &T) -> bool {
*self.0 == *other
}
}
impl<T: std::fmt::Debug> std::fmt::Debug for Shared<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl<T: Serialize> Serialize for Shared<T> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Shared<T> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
T::deserialize(d).map(Shared::new)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::text::TextStyleDef;
use crate::variables::{Variable, VariableKind};
use crate::{Document, NodeId, SavedSelection};
/// A document with every shared resource set, changed only through `push`/`insert` (as code
/// written for the plain values does).
fn doc_with_resources() -> Document {
let mut d = Document::new(100.0, 100.0);
let swatch = d.swatches[1].clone();
d.swatches.push(swatch);
d.swatch_groups.truncate(1);
d.graphic_styles.truncate(2);
d.char_styles.push(TextStyleDef { name: "Emphasis".into(), attrs: json!({"size": 14}).as_object().cloned().unwrap_or_default() });
d.para_styles.push(TextStyleDef { name: "Body".into(), attrs: Default::default() });
d.saved_selections.push(SavedSelection { name: "Saved".into(), objects: vec![NodeId(1)] });
d.variables.variables.push(Variable { name: "Name".into(), kind: VariableKind::Text });
d.variables.bindings.entry(NodeId(1)).or_default().insert("Name".into());
d.unknown.insert("foreign".into(), json!({"keep": [1, 2]}));
d
}
/// Shared document resources save as the values themselves: the saved form is unchanged.
#[test]
fn shared_resources_save_and_load_as_their_values() {
let d = doc_with_resources();
let v = serde_json::to_value(&d).unwrap();
assert_eq!(v["swatches"], serde_json::to_value(&*d.swatches).unwrap());
assert_eq!(v["swatch_groups"], serde_json::to_value(&*d.swatch_groups).unwrap());
assert_eq!(v["graphic_styles"], serde_json::to_value(&*d.graphic_styles).unwrap());
assert_eq!(v["char_styles"], json!([{"name": "Emphasis", "attrs": {"size": 14}}]));
assert_eq!(v["para_styles"], json!([{"name": "Body", "attrs": {}}]));
assert_eq!(v["saved_selections"], json!([{"name": "Saved", "objects": [1]}]));
assert_eq!(v["variables"], json!({"variables": [{"name": "Name", "kind": "text"}], "bindings": {"1": ["Name"]}}));
assert_eq!(v["unknown"], json!({"foreign": {"keep": [1, 2]}}));
let text = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&text).unwrap();
assert_eq!(back, d);
assert_eq!(serde_json::to_string(&back).unwrap(), text, "saving again gives the same bytes");
// Empty ones are left out, as before.
let v = serde_json::to_value(Document::new(10.0, 10.0)).unwrap();
for k in ["char_styles", "para_styles", "saved_selections", "variables", "unknown"] {
assert!(v.get(k).is_none(), "{k} is left out when empty");
}
}
/// A copy of a document shares its resources until one copy changes one of them.
#[test]
fn copies_share_resources_until_changed() {
let a = doc_with_resources();
let mut b = a.clone();
assert!(super::Shared::ptr_eq(&a.swatches, &b.swatches) && super::Shared::ptr_eq(&a.unknown, &b.unknown));
b.swatches.pop();
assert!(!super::Shared::ptr_eq(&a.swatches, &b.swatches));
assert_eq!(a.swatches.len(), b.swatches.len() + 1, "the other copy keeps its own");
assert!(super::Shared::ptr_eq(&a.graphic_styles, &b.graphic_styles), "the rest stays shared");
}
}
+4 -4
View File
@@ -177,13 +177,13 @@ impl Document {
}
/// Is `id` a user slice or an object with slice options?
pub fn is_slice(&self, id: NodeId) -> bool {
self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some())
self.slice(id).is_some() || self.node(id).is_some_and(|n| n.extras().slice.is_some())
}
/// The options of slice `id` (user or object).
pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> {
match self.slice(id) {
Some(s) => Some(&s.options),
None => self.node(id)?.slice.as_deref(),
None => self.node(id)?.extras().slice.as_deref(),
}
}
/// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's
@@ -191,14 +191,14 @@ impl Document {
pub fn slice_bounds(&self, id: NodeId) -> Option<Rect> {
match self.slice(id) {
Some(s) => Some(s.rect),
None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(),
None => self.node(id).filter(|n| n.extras().slice.is_some())?.visual_bounds(),
}
}
/// The visible objects with slice options and their visual bounds, in paint order.
pub fn object_slices(&self) -> Vec<(NodeId, Rect)> {
fn walk(nodes: &[Arc<Node>], out: &mut Vec<(NodeId, Rect)>) {
for n in nodes.iter().filter(|n| n.visible) {
if n.slice.is_some()
if n.extras().slice.is_some()
&& let Some(b) = n.visual_bounds()
{
out.push((n.id, b));
+6 -1
View File
@@ -163,7 +163,12 @@ impl Document {
}
})
};
self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n));
self.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| &p.art))
.for_each(|n| tree(n));
self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it)));
}
}
+156
View File
@@ -0,0 +1,156 @@
//! The batched lookups ([`Document::index_paths`], `node_at`, `node_mut_at`, `ancestry_at`) agree
//! with the one-id ones on nested trees with missing and duplicated ids.
use super::*;
use vectorcraft_geom::shapes;
/// Layers with paths and groups nested `depth` deep; every 7th id is reused (a damaged file), so
/// first-in-tree-order matters.
fn tree(seed: u64) -> (Document, Vec<NodeId>) {
let mut d = Document::new(612.0, 792.0);
let mut r = seed | 1;
let mut next = |n: u64| {
r ^= r << 13;
r ^= r >> 7;
r ^= r << 17;
r % n
};
let mut ids = vec![];
let leaf = |d: &mut Document, ids: &mut Vec<NodeId>| {
let id = if ids.len() % 7 == 6 { ids[ids.len() / 2] } else { d.alloc_id() };
ids.push(id);
Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art()))
};
for l in 0..3 {
let lid = d.alloc_id();
let mut layer = Node::layer(lid, &format!("L{l}"), LayerColor::Preset(0));
ids.push(lid);
for _ in 0..(5 + next(10)) {
let child = if next(3) == 0 {
let mut inner: Vec<Arc<Node>> = (0..(1 + next(4))).map(|_| leaf(&mut d, &mut ids)).collect();
for _ in 0..next(3) {
let gid = d.alloc_id();
ids.push(gid);
inner = vec![Arc::new(Node::group(gid, inner))];
}
let gid = d.alloc_id();
ids.push(gid);
Arc::new(Node::group(gid, inner))
} else {
leaf(&mut d, &mut ids)
};
if let Some(ch) = layer.children_mut() {
ch.push(child);
}
}
d.layers.push(Arc::new(layer));
}
ids.push(NodeId(999_999)); // not in the tree
(d, ids)
}
#[test]
fn batched_lookups_match_one_at_a_time() {
for seed in 1..40 {
let (d, ids) = tree(seed);
let paths = d.index_paths(ids.iter().copied());
for id in &ids {
assert_eq!(paths.get(id).cloned(), d.index_path(*id), "seed {seed} id {id:?}");
let p = paths.get(id);
assert_eq!(p.and_then(|p| d.node_at(p)).map(|n| n as *const Node), d.node(*id).map(|n| n as *const Node), "seed {seed} node {id:?}");
assert_eq!(p.and_then(|p| d.ancestry_at(p)), d.ancestry(*id), "seed {seed} ancestry {id:?}");
}
// A subset, and nothing.
let some: Vec<NodeId> = ids.iter().copied().step_by(3).collect();
let sub = d.index_paths(some.iter().copied());
assert!(some.iter().all(|id| sub.get(id).cloned() == d.index_path(*id)));
assert!(d.index_paths(std::iter::empty()).is_empty());
// Mutable access reaches the same node as node_mut, copy-on-write.
let mut a = d.clone();
let mut b = d.clone();
for id in ids.iter().step_by(5) {
if let Some(n) = a.node_mut(*id) {
n.opacity = 0.25;
}
if let Some(n) = d.index_path(*id).and_then(|p| b.node_mut_at(&p)) {
n.opacity = 0.25;
}
}
assert_eq!(serde_json::to_string(&a).ok(), serde_json::to_string(&b).ok(), "seed {seed}");
}
}
/// What a walk of the tree gives for every id, checked against the cached lookups.
fn agree(d: &Document, ids: &[NodeId], what: &str) {
agree_except(d, ids, None, what);
}
/// [`agree`], except for `dup`, an id given a second node after the cache was built: lookups of it
/// may return either of its nodes (see `id_index`), but always one with that id.
fn agree_except(d: &Document, ids: &[NodeId], dup: Option<NodeId>, what: &str) {
if let Some(dup) = dup {
assert_eq!(d.node(dup).map(|n| n.id), Some(dup), "{what}: node {dup:?} has its id");
}
let ids: Vec<NodeId> = ids.iter().copied().filter(|id| Some(*id) != dup).collect();
let ids = &ids[..];
for id in ids {
let walked = d.walk_index_path(*id);
assert_eq!(d.index_path(*id), walked, "{what}: index_path {id:?}");
let by_walk = walked.as_deref().and_then(|p| d.node_at(p)).map(|n| n as *const Node);
assert_eq!(d.node(*id).map(|n| n as *const Node), by_walk, "{what}: node {id:?}");
}
let batched = d.index_paths(ids.iter().copied());
for id in ids {
assert_eq!(batched.get(id).cloned(), d.walk_index_path(*id), "{what}: index_paths {id:?}");
}
}
/// The id cache never answers differently from a walk, however the tree is changed behind its
/// back (through the public fields), including in a clone that shares the cache.
#[test]
fn cached_lookups_follow_any_edit_of_the_tree() {
for seed in 1..30 {
let (mut d, ids) = tree(seed);
agree(&d, &ids, "fresh");
let snapshot = d.clone(); // shares the cache
// Insert at the front of the first layer: every path in it shifts.
let extra = d.alloc_id();
let leaf = Arc::new(Node::path(extra, shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
ch.insert(0, leaf.clone());
}
let mut all = ids.clone();
all.push(extra);
agree(&d, &all, "inserted at the front");
// Remove a child, swap two, and move one into another layer.
if let Some(ch) = Arc::make_mut(&mut d.layers[1]).children_mut()
&& ch.len() > 3
{
ch.remove(1);
ch.swap(0, 2);
}
let moved = Arc::make_mut(&mut d.layers[2]).children_mut().and_then(|ch| (!ch.is_empty()).then(|| ch.remove(0)));
if let Some(m) = moved
&& let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut()
{
ch.push(m);
}
agree(&d, &all, "removed, swapped and moved");
// A new duplicate of an existing id, earlier in tree order than the original.
let dup = ids[ids.len() / 3];
let copy = Arc::new(Node::path(dup, shapes::rectangle(Rect::new(0.0, 0.0, 2.0, 2.0)), Appearance::default_art()));
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
ch.insert(0, copy);
}
agree_except(&d, &all, Some(dup), "a duplicate inserted earlier");
// Delete a whole layer, then look everything up again, missing ids included.
d.layers.remove(1);
agree_except(&d, &all, Some(dup), "a layer deleted");
// A rebuilt cache (as after loading a file) knows the duplicate and walks for it.
let mut fresh = d.clone();
fresh.layers = d.layers.clone();
fresh.index = Default::default();
agree(&fresh, &all, "a damaged tree, cached afresh");
// The clone made before all this still answers for its own tree.
agree(&snapshot, &ids, "the earlier clone");
}
}
+4 -3
View File
@@ -30,7 +30,7 @@ fn an_object_slice_follows_its_object_and_numbers_run_by_rows() {
let mut d = Document::new(300.0, 200.0);
let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0));
assert!(d.slice_layout().is_empty(), "no slices, no auto slices");
d.node_mut(a).unwrap().slice = Some(Box::default());
d.node_mut(a).unwrap().extras_mut().slice = Some(Box::default());
let lay = d.slice_layout();
let own = lay.iter().find(|s| s.id == Some(a)).unwrap();
assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0)));
@@ -69,14 +69,15 @@ fn clip_to_artboard_clips_slices_and_off_covers_the_art() {
fn slices_survive_a_native_round_trip_and_old_files_load() {
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0));
d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "<b>hi</b>".into(), ..Default::default() }));
d.node_mut(a).unwrap().extras_mut().slice =
Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "<b>hi</b>".into(), ..Default::default() }));
let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0));
d.slice_mut(s).unwrap().options.url = "https://example.com".into();
d.slices_clip_to_artboard = false;
let json = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&json).unwrap();
assert_eq!(back.slices, d.slices);
assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice);
assert_eq!(back.node(a).unwrap().extras().slice, d.node(a).unwrap().extras().slice);
assert!(!back.slices_clip_to_artboard);
// A document without slices writes none of the new keys (older readers see what they knew).
let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap();
+65
View File
@@ -100,6 +100,47 @@ pub struct CharStyle {
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps
/// the digits that were typed; only the glyphs change (like All Caps).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Digits {
/// Each digit as typed.
#[default]
Default,
/// Western digits 0–9 (the digits Arabic typography calls Arabic).
Arabic,
/// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic.
Hindi,
/// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu.
Farsi,
}
impl Digits {
/// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c`
/// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set.
pub fn map(self, c: char) -> char {
let value = match c {
'0'..='9' => c as u32 - '0' as u32,
'\u{660}'..='\u{669}' => c as u32 - 0x660,
'\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0,
_ => return c,
};
let base = match self {
Digits::Default => return c,
Digits::Arabic => '0' as u32,
Digits::Hindi => 0x660,
Digits::Farsi => 0x6F0,
};
char::from_u32(base + value).unwrap_or(c)
}
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
@@ -212,6 +253,7 @@ impl Default for CharStyle {
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
small_caps: None,
digits: Digits::Default,
}
}
}
@@ -1238,6 +1280,29 @@ impl TextObject {
mod tests {
use super::*;
/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and
/// are saved only when set.
#[test]
fn digits_map_between_the_numeral_systems() {
let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() };
for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] {
assert_eq!(map(Digits::Default, s), s);
assert_eq!(map(Digits::Arabic, s), "0123456789");
assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩");
assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹");
}
// Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets.
assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔");
let st = CharStyle::default();
assert!(serde_json::to_value(&st).unwrap().get("digits").is_none());
let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() };
assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi");
// Styles written before the attribute existed (no `digits` key) read as Default.
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().digits, Digits::Default);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
+3 -3
View File
@@ -67,7 +67,7 @@ impl TraceView {
impl Node {
/// The View of an Image Trace object (the tracing result for anything else, or an unknown id).
pub fn trace_view(&self) -> TraceView {
self.trace.as_deref().map(TraceView::of).unwrap_or_default()
self.extras().trace.as_deref().map(TraceView::of).unwrap_or_default()
}
}
@@ -84,9 +84,9 @@ mod tests {
}
let mut g = Node::group(crate::NodeId(1), vec![]);
assert_eq!(g.trace_view(), TraceView::Result);
g.trace = Some(Box::new(serde_json::json!({"view": "outlines"})));
g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "outlines"})));
assert_eq!(g.trace_view(), TraceView::Outlines);
g.trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
assert_eq!(g.trace_view(), TraceView::Result);
}
}
+19 -18
View File
@@ -163,24 +163,25 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
let id = d.alloc_id();
let kind = path_kind(d, path, rule);
Node {
id,
name: None,
appearance: Appearance { items: vec![item], ..Default::default() },
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
mask: None,
trace: None,
wrap: None,
graph: None,
// The group keeps the URL (one link around the pieces).
attrs: None,
kind,
..n.clone()
}
let mut out = n.clone();
out.id = id;
out.name = None;
out.appearance = Appearance { items: vec![item], ..Default::default() }.into();
out.opacity = 1.0;
out.blend = Default::default();
out.isolate = false;
out.knockout = Default::default();
out.knockout_shape = false;
out.kind = kind;
let mut extras = out.extras_mut();
extras.mask = None;
extras.trace = None;
extras.wrap = None;
extras.graph = None;
// The group keeps the URL (one link around the pieces).
extras.attrs = None;
drop(extras);
out
}
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
+10 -2
View File
@@ -12,8 +12,8 @@
//! the geometry.
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
//! renderer, the exporters and Outline Stroke.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and
//! default values. Missing parameters always fall back to those defaults.
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
//! through their outlines.
//! - [`evaluate_container`] turns a group's or layer's own fills, strokes and geometry effects into
@@ -154,6 +154,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const ARTISTIC: &[&str] = &["Effect", "Artistic"];
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
const DISTORT: &[&str] = &["Effect", "Distort"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
@@ -298,6 +299,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
r(
"artistic.filmGrain",
"Film Grain…",
ARTISTIC,
"{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}),
),
r(
"blur.radial",
"Radial Blur…",
+2 -2
View File
@@ -32,8 +32,8 @@ pub fn crop_marks_art(n: &Node) -> Option<Node> {
g.opacity = std::mem::replace(&mut object.opacity, 1.0);
g.blend = std::mem::take(&mut object.blend);
g.isolate = std::mem::take(&mut object.isolate);
g.mask = object.mask.take();
g.attrs = object.attrs.take();
g.extras_mut().mask = object.extras_mut().mask.take();
g.extras_mut().attrs = object.extras_mut().attrs.take();
if let Some(ch) = g.children_mut() {
*ch = vec![Arc::new(object), Arc::new(marks)];
}
+136
View File
@@ -0,0 +1,136 @@
//! Effect › Artistic. Original filters built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
//! These are visual approximations, not reproductions of a proprietary algorithm.
use super::PixelSpace;
use super::pixelate::{Place, cell, rand};
use super::texture::{luma, premultiply, straight};
use vectorcraft_geom::Point;
const SALT_FILM: u64 = 256;
/// Keep the last cell's four corner values: magnified adjacent pixels often share a cell.
/// The cache changes only cost, never the field, and needs constant storage per raster.
#[derive(Default)]
struct CellNoise {
last: Option<(i64, i64, [f64; 4])>,
}
impl CellNoise {
fn at(&mut self, salt: u64, q: Point) -> f64 {
let (Some(i), Some(j)) = (cell(q.x), cell(q.y)) else { return 0.0 };
let corners = match self.last {
Some((x, y, values)) if x == i && y == j => values,
_ => {
let at = |di: i64, dj: i64| 2.0 * rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0) - 1.0;
let values = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)];
self.last = Some((i, j, values));
values
}
};
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
let (fx, fy) = (smooth(q.x - i as f64), smooth(q.y - j as f64));
let [a, b, c, d] = corners;
let top = a + (b - a) * fx;
let bottom = c + (d - c) * fx;
// Divide by the root sum of squared interpolation weights so corners and cell
// interiors have the same variance, rather than imprinting a contrast grid.
let norm = ((fx * fx + (1.0 - fx).powi(2)) * (fy * fy + (1.0 - fy).powi(2))).sqrt();
(top + (bottom - top) * fy) / norm
}
}
#[derive(Default)]
struct FilmNoise {
low: CellNoise,
high: CellNoise,
}
impl FilmNoise {
fn at(&mut self, q: Point) -> f32 {
// Fixed independent rotations (0.6 and -0.35 radians) and a second-octave offset
// keep the two lattices from lining up with pixel rows or with each other.
let low = Point::new(0.8253356149096783 * q.x - 0.5646424733950354 * q.y, 0.5646424733950354 * q.x + 0.8253356149096783 * q.y);
let high = Point::new(
2.0 * (0.9393727128473789 * q.x + 0.34289780745545134 * q.y) + 0.37,
2.0 * (-0.34289780745545134 * q.x + 0.9393727128473789 * q.y) - 0.37,
);
// Normalising the blend can exceed the corner range, so bound the final field.
(0.8 * (0.65 * self.low.at(SALT_FILM, low) + 0.35 * self.high.at(SALT_FILM + 1, high))).clamp(-1.0, 1.0) as f32
}
}
/// Bounded footprint sampling during minification, with attenuation for very large pixels.
/// Up to four samples approximate area filtering, not exact integration of every grain.
fn grain_at(place: &Place, field: &mut FilmNoise, x: usize, y: usize) -> f32 {
if place.px <= 0.5 {
return field.at(place.at(x, y));
}
let mut sample = |dx, dy| field.at(place.to_rel * Point::new(x as f64 + dx, y as f64 + dy));
let diagonal = 0.5 * (sample(0.25, 0.25) + sample(0.75, 0.75));
// Blend sampling modes rather than changing the texture's strength suddenly at a zoom.
if place.px < 0.75 {
let t = ((place.px - 0.5) / 0.25) as f32;
return field.at(place.at(x, y)) * (1.0 - t) + diagonal * t;
}
if place.px <= 1.0 {
return diagonal;
}
let other = 0.5 * (sample(0.75, 0.25) + sample(0.25, 0.75));
let t = (0.5 * ((place.px - 1.0) / 0.5).min(1.0)) as f32;
// The four samples already average about a two-point footprint. Only wider pixels need
// extra attenuation; /px alone suppressed coarse grain twice (checked against dense sampling).
(diagonal * (1.0 - t) + other * t) / (place.px / 2.0).max(1.0) as f32
}
/// Film Grain: monochrome grain in shadows and midtones, with brighter highlights as
/// `highlight` (0..20) widens their area. `intensity` (0..10) increases the highlight lift and
/// quiets highlight grain, including when the Highlight Area is zero;
/// `grain` (0..20) sets the noise strength. The smooth grain field lies relative to the object's
/// bounds' centre, following translations and redraws; changing the bounds shifts the pattern.
/// Its scale is in document units; minification uses bounded approximate footprint filtering.
/// Coverage is preserved, including fully transparent pixels. Constant work and no allocations.
pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, grain: f64, highlight: f64, intensity: f64) {
if ![grain, highlight, intensity].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let grain = (grain / 20.0).clamp(0.0, 1.0) as f32;
let highlight = (highlight / 20.0).clamp(0.0, 1.0) as f32;
let intensity = (intensity / 10.0).clamp(0.0, 1.0) as f32;
if grain == 0.0 && (intensity == 0.0 || highlight == 0.0) {
return;
}
let mut field = FilmNoise::default();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let c = straight(*p);
let brightness = luma(c);
// Wider highlight areas include more midtones, without lifting black or growing a halo.
let threshold = 0.85 - 0.6 * highlight;
let t = ((brightness - threshold) / (1.0 - threshold)).clamp(0.0, 1.0);
let light = t * t * (3.0 - 2.0 * t) * highlight * intensity * 0.65;
// Keep the grain strength even across shadows and midtones, then quiet it smoothly
// above mid-grey to a quarter-strength floor at full intensity, before highlight
// headroom. This keeps a softer grain on cream. Black takes light speckles after
// clipping; white stays clean.
// This tonal curve is an interpretation of the public description, not a measured
// proprietary transfer function. Shared offsets stay neutral until a channel clips.
let upper = ((brightness - 0.5) * 2.0).clamp(0.0, 1.0);
let upper = upper * upper * (3.0 - 2.0 * upper);
let amplitude = (0.4 * (1.0 - upper * (0.5 + 0.5 * intensity))).max(0.1).min(1.0 - brightness);
let strength = amplitude * grain * (1.0 - light);
// With no highlight lift the starting premultiplied channels are whole bytes. A
// bounded offset below half a byte cannot change rounding, so avoid hashing it.
// Leave a small margin for straight/premultiplied floating-point roundoff.
if light == 0.0 && strength * f32::from(p[3]) < 0.45 {
return;
}
let noise = if strength > 0.0 { grain_at(&place, &mut field, x, y) * strength } else { 0.0 };
let c = c.map(|v| v + (1.0 - v) * light + noise);
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
+16 -4
View File
@@ -1,4 +1,4 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
@@ -12,6 +12,7 @@
//! cost doesn't grow with the radius, and sample counts come from the effect's quality.
//! - Beyond the raster's edges is transparency.
mod artistic;
mod blur;
mod brushstrokes;
mod distort;
@@ -32,7 +33,8 @@ pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 27] = [
pub const PIXEL_EFFECTS: [&str; 28] = [
"artistic.filmGrain",
"blur.radial",
"blur.smart",
"brushStrokes.accentedEdges",
@@ -66,6 +68,9 @@ pub const PIXEL_EFFECTS: [&str; 27] = [
/// are in document units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
RadialBlur { amount: f64, zoom: bool, passes: u32 },
@@ -192,6 +197,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
};
Some(match id {
"artistic.filmGrain" => PixelFx::FilmGrain {
grain: num(p, "grain", 4.0).clamp(0.0, 20.0),
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
},
"blur.radial" => PixelFx::RadialBlur {
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
@@ -338,7 +348,8 @@ impl PixelFx {
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::ColorHalftone { .. }
PixelFx::FilmGrain { .. }
| PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
| PixelFx::Grain { .. }
@@ -378,7 +389,7 @@ impl PixelFx {
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::Mezzotint { .. } => Some(0.0),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
@@ -399,6 +410,7 @@ impl PixelFx {
let Some(px) = pixels(data, w, h) else { return };
let to_px = |len: f64| len / space.px.max(1e-9);
match *self {
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
+412
View File
@@ -45,6 +45,418 @@ fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
#[test]
fn film_grain_parameters_are_bounded_and_neutral_settings_preserve_the_art() {
assert_eq!(fx("artistic.filmGrain", json!({})), PixelFx::FilmGrain { grain: 4.0, highlight: 0.0, intensity: 10.0 });
assert_eq!(
fx("artistic.filmGrain", json!({"grain": 1e308, "highlightArea": -100, "intensity": 200})),
PixelFx::FilmGrain { grain: 20.0, highlight: 0.0, intensity: 10.0 }
);
assert_eq!(fx("artistic.filmGrain", json!({"grain": "1e999", "intensity": []})), fx("artistic.filmGrain", json!({})));
let (w, h) = (24, 18);
let src = image(w, h, |x, y| [(3 * x) as u8, (2 * y) as u8, 12, 128]);
let mut out = src.clone();
let neutral = fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": 0, "intensity": 0}));
neutral.apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src);
assert_eq!(neutral.outset(Rect::new(0.0, 0.0, w as f64, h as f64)), 0.0);
assert_eq!(neutral.reach(), Some(0.0));
}
#[test]
fn film_grain_is_monochrome_and_quieter_in_highlights() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 20, "intensity": 0}));
let mut changes = Vec::new();
for tone in [64, 128, 240] {
let mut out = image(w, h, |_, _| grey(tone));
grain.apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
changes.push(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(tone))).sum::<f64>() / (w * h) as f64);
}
assert!(changes[0] > 3.0 * changes[2] && changes[1] > 3.0 * changes[2], "shadows and midtones take more grain: {changes:?}");
}
#[test]
fn film_grain_highlight_area_and_intensity_change_the_tones() {
let (w, h) = (16, 16);
let apply = |tone, area, intensity| {
let mut out = image(w, h, |_, _| grey(tone));
fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": area, "intensity": intensity})).apply(&mut out, w, h, &space(w, h));
out[0]
};
assert_eq!(apply(64, 0, 10), 64, "intensity alone doesn't recolour shadows");
assert_eq!(apply(192, 0, 10), 192, "intensity alone doesn't add global contrast");
assert!(apply(192, 20, 10) > apply(192, 20, 0), "intensity sets highlight strength");
let tone = 192;
let mut strong = image(w, h, |_, _| grey(tone));
let mut quiet = strong.clone();
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 0})).apply(&mut strong, w, h, &space(w, h));
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 10})).apply(&mut quiet, w, h, &space(w, h));
let spread = |data: &[u8]| data.as_chunks::<4>().0.iter().map(|p| u32::from(p[0].abs_diff(tone))).sum::<u32>();
assert!(spread(&strong) > spread(&quiet), "Intensity also quiets highlight grain with the default Highlight Area");
let highlights = [0, 10, 20].map(|area| apply(192, area, 10));
assert!(highlights.windows(2).all(|p| p[0] < p[1]), "wider highlight area lifts more midtones: {highlights:?}");
assert_eq!(apply(0, 20, 10), 0, "black doesn't become a white background");
}
#[test]
fn film_grain_preserves_coverage_without_a_halo() {
let (w, h) = (40, 30);
let src = image(w, h, |x, y| match (x + y) % 5 {
0 => [0; 4],
1 => [1, 0, 1, 1],
2 => [75, 12, 25, 128],
_ => [180, 60, 90, 255],
});
let mut out = src.clone();
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
for (p, original) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], original[3]);
assert!(p[..3].iter().all(|c| *c <= p[3]), "premultiplied: {p:?}");
if original[3] == 0 {
assert_eq!(p, original);
}
}
}
#[test]
fn film_grain_pattern_survives_redraws_moves_resolution_changes_and_tiles() {
let (w, h) = (32, 24);
let src = image(w, h, |_, _| grey(128));
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
let mut whole = src.clone();
grain.apply(&mut whole, w, h, &space(w, h));
let mut again = src.clone();
grain.apply(&mut again, w, h, &space(w, h));
assert_eq!(whole, again);
let moved =
PixelSpace { to_doc: Affine::translate((17.0, -9.0)), center: space(w, h).center + vectorcraft_geom::Vec2::new(17.0, -9.0), ..space(w, h) };
let mut shifted = src;
grain.apply(&mut shifted, w, h, &moved);
assert_eq!(whole, shifted);
let mut two = image(2 * w, 2 * h, |_, _| grey(128));
grain.apply(&mut two, 2 * w, 2 * h, &PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) });
let mut difference = 0u64;
for y in 0..h {
for x in 0..w {
let down = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| u32::from(at(&two, 2 * w, 2 * x + dx, 2 * y + dy)[0]));
let down = (down.iter().sum::<u32>() / 4) as u8;
difference += u64::from(at(&whole, w, x, y)[0].abs_diff(down));
}
}
let mean_delta = difference as f64 / (w * h) as f64;
let fine_deviation = film_grain_deviation(two.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
// Two diagonal samples approximate the four point samples in the finer raster. Bound
// their error relative to grain strength, so a stronger tonal curve does not fail it.
assert!(mean_delta < 0.25 * fine_deviation, "the field keeps its document scale: mean delta {mean_delta}, point deviation {fine_deviation}");
let (tw, th) = (13, 11);
let mut tile = image(tw, th, |_, _| grey(128));
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: Affine::translate((5.0, 3.0)), ..space(w, h) });
for y in 0..th {
for x in 0..tw {
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 5, y + 3), "no tile seam");
}
}
}
#[test]
fn film_grain_keeps_colour_without_an_extra_contrast_curve_and_scales_with_alpha() {
let (w, h) = (256, 256);
let original = [180, 100, 60, 255];
let mut out = image(w, h, |_, _| original);
// Intensity with no highlight area must not silently change colour or clip channels.
fx("artistic.filmGrain", json!({"grain": 0, "intensity": 10})).apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| *p == original));
let grain = fx("artistic.filmGrain", json!({"grain": 4}));
grain.apply(&mut out, w, h, &space(w, h));
for p in out.as_chunks::<4>().0 {
// A neutral floating offset can round either side of a half-byte tie in each channel.
assert!((i32::from(p[0]) - i32::from(p[1]) - 80).abs() <= 1, "one neutral offset, no channel clipping");
assert!((i32::from(p[1]) - i32::from(p[2]) - 40).abs() <= 1);
}
// These straight colours have exact premultiplied representations at alpha 85 and 170.
let colour = [180, 120, 60];
let mut opaque = image(w, h, |_, _| [colour[0], colour[1], colour[2], 255]);
grain.apply(&mut opaque, w, h, &space(w, h));
for alpha in [85u8, 170] {
let mut translucent = image(w, h, |_, _| {
let [r, g, b] = colour.map(|c| (u32::from(c) * u32::from(alpha) / 255) as u8);
[r, g, b, alpha]
});
grain.apply(&mut translucent, w, h, &space(w, h));
for (p, full) in translucent.as_chunks::<4>().0.iter().zip(opaque.as_chunks::<4>().0) {
for c in 0..3 {
let expected = (u32::from(full[c]) * u32::from(alpha) + 127) / 255;
assert!(i32::from(p[c]).abs_diff(expected as i32) <= 1, "alpha scales colour consistently: {p:?}, {full:?}");
}
assert_eq!(p[3], alpha);
}
}
let mut white = image(w, h, |_, _| grey(255));
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20, "intensity": 10})).apply(&mut white, w, h, &space(w, h));
assert!(white.as_chunks::<4>().0.iter().all(|p| *p == grey(255)), "white stays clean");
}
#[test]
fn film_grain_is_visible_on_dark_fills_with_even_shadow_and_midtone_strength() {
let (w, h) = (128, 96);
let grain = fx("artistic.filmGrain", json!({}));
let render = |tone| {
let mut out = image(w, h, |_, _| grey(tone));
grain.apply(&mut out, w, h, &space(w, h));
out
};
let black = render(0);
assert!(black.as_chunks::<4>().0.iter().any(|p| p[0] > 2), "pure black takes light grain speckles");
assert!(film_grain_deviation(black.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))) > 2.0);
let dark = render(26);
let mid = render(128);
let [dark, mid] = [dark, mid].map(|out| film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
assert!(dark > 4.0 && (dark / mid - 1.0).abs() < 0.02, "default grain must remain even across shadows and midtones: {dark}, {mid}");
}
#[test]
fn film_grain_leaves_soft_but_visible_grain_on_light_fills_at_defaults() {
let (w, h) = (128, 96);
let colour = [245, 240, 225, 255];
let mut cream = image(w, h, |_, _| colour);
fx("artistic.filmGrain", json!({})).apply(&mut cream, w, h, &space(w, h));
let deviation = film_grain_deviation(cream.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
let changed = cream.as_chunks::<4>().0.iter().filter(|p| **p != colour).count();
assert!((0.5..3.0).contains(&deviation), "soft grain must remain visible on cream: {deviation}");
assert!(changed > w * h / 4, "grain should cover the fill, not just a few outliers: {changed}");
assert!(cream.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
}
#[test]
fn film_grain_tiles_match_at_fractional_centres_and_rotated_scales() {
let (w, h) = (40, 32);
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
for scale in [0.7, 2.5] {
let s = PixelSpace { to_doc: Affine::rotate(0.37) * Affine::scale(scale), px: scale, center: Point::new(12.375, -4.625), ..space(w, h) };
let mut whole = image(w, h, |_, _| grey(128));
grain.apply(&mut whole, w, h, &s);
let (tw, th) = (19, 13);
let mut tile = image(tw, th, |_, _| grey(128));
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: s.to_doc * Affine::translate((7.0, 5.0)), ..s });
for y in 0..th {
for x in 0..tw {
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 7, y + 5), "fractional rotated tile seam at scale {scale}");
}
}
}
}
#[test]
fn film_grain_minification_quiets_the_noise_instead_of_aliasing_at_full_strength() {
let (w, h) = (64, 64);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let spread = |scale| {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, ..space(w, h) });
out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(128))).sum::<f64>() / (w * h) as f64
};
let normal = spread(1.0);
assert!(normal > 2.0);
assert!(spread(2.0) < normal && spread(4.0) < normal / 2.0);
}
#[test]
fn film_grain_sampling_modes_blend_without_a_zoom_threshold_pop() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for boundary in [0.5, 0.75, 1.0, 1.5, 2.0] {
// Hold sample coordinates fixed to isolate the footprint-mode boundary. Changing
// to_doc as well moves the texture, which can exceed this limit far from the origin.
let render = |scale| {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(boundary), px: scale, ..space(w, h) });
out
};
let (left, right) = (render(boundary - 1e-4), render(boundary + 1e-4));
let change = left.iter().zip(right).map(|(a, b)| f64::from(a.abs_diff(b))).sum::<f64>() / left.len() as f64;
assert!(change < 0.25, "no sudden grain change around px={boundary}: {change}");
}
}
#[test]
fn film_grain_coarse_variance_tracks_an_independent_dense_footprint_average() {
let (w, h, samples) = (32, 32, 16);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let deviation = |values: &[f64]| {
let mean = values.iter().sum::<f64>() / values.len() as f64;
(values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64).sqrt()
};
for scale in [1.0, 2.0, 4.0, 8.0] {
let s = PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
..space(w, h)
};
let mut actual = image(w, h, |_, _| grey(128));
grain.apply(&mut actual, w, h, &s);
// 16×16 independent point samples per coarse pixel; the finest raster never takes
// the footprint branch, so this does not reproduce the filter's quadrature weights.
let fine_scale = scale / samples as f64;
let mut fine = image(w * samples, h * samples, |_, _| grey(128));
grain.apply(&mut fine, w * samples, h * samples, &PixelSpace { to_doc: Affine::scale(fine_scale), px: fine_scale, ..s });
let mut reference = Vec::new();
let mut coarse = Vec::new();
for y in 0..h {
for x in 0..w {
let mut sum = 0u32;
for dy in 0..samples {
for dx in 0..samples {
sum += u32::from(at(&fine, w * samples, x * samples + dx, y * samples + dy)[0]);
}
}
reference.push(f64::from(sum) / (samples * samples) as f64);
coarse.push(f64::from(at(&actual, w, x, y)[0]));
}
}
let ratio = deviation(&coarse) / deviation(&reference);
assert!((0.75..=1.25).contains(&ratio), "bounded footprint approximation at px={scale}: relative deviation {ratio}");
}
}
#[test]
fn film_grain_strength_is_stable_under_subpixel_centre_shifts() {
let (w, h) = (192, 128);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for scale in [0.25, 0.5, 1.0] {
let mut deviations = Vec::new();
for phase in [0.0, 0.25, 0.5, 0.75] {
let mut out = image(w, h, |_, _| grey(128));
let s = PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + phase, h as f64 * scale / 2.0 + phase),
..space(w, h)
};
grain.apply(&mut out, w, h, &s);
deviations.push(film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
}
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
let max = deviations.iter().copied().fold(0.0, f64::max);
assert!(min > 1.0 && max / min < 1.06, "centre phase must not change grain strength at px={scale}: {deviations:?}");
}
}
fn film_grain_deviation(values: impl Iterator<Item = f64>) -> f64 {
let (mut count, mut sum, mut square_sum) = (0.0, 0.0, 0.0);
for v in values {
count += 1.0;
sum += v;
square_sum += v * v;
}
(square_sum / count - (sum / count).powi(2)).max(0.0).sqrt()
}
#[test]
fn film_grain_preserves_byte_values_when_the_grain_is_too_small_to_round() {
let (w, h) = (256, 256);
let src = image(w, h, |x, y| {
let alpha = y as u8;
[(x as u8).min(alpha), (255 - x as u8).min(alpha), (x / 2) as u8 % alpha.max(1), alpha]
});
let mut out = src.clone();
// Even the maximum field value cannot change half a premultiplied byte at this strength.
fx("artistic.filmGrain", json!({"grain": 0.01})).apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src, "invisible noise preserves every colour/coverage combination");
}
#[test]
fn film_grain_cmy_and_k_planes_share_the_noise_and_preserve_coverage() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 14}));
// Complemented inks of a rich black, premultiplied by the same coverage.
let mut cmy = image(w, h, |_, _| [70, 80, 90, 200]);
let mut k = image(w, h, |_, _| [40, 40, 40, 200]);
grain.apply(&mut cmy, w, h, &PixelSpace { channels: Channels::CmyPlane, ..space(w, h) });
grain.apply(&mut k, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
let mut changed = 0;
for (cmy, k) in cmy.as_chunks::<4>().0.iter().zip(k.as_chunks::<4>().0) {
assert_eq!((cmy[3], k[3]), (200, 200));
assert!(cmy[..3].iter().all(|v| *v <= 200) && k[..3].iter().all(|v| *v <= 200));
assert_eq!(k[0], k[1]);
assert_eq!(k[1], k[2]);
let (dc, dk) = (i16::from(cmy[0]) - 70, i16::from(k[0]) - 40);
if dc != 0 && dk != 0 {
assert_eq!(dc.signum(), dk.signum(), "the same field modulates both ink planes");
changed += 1;
}
}
assert!(changed > w * h / 2);
}
#[test]
fn film_grain_does_not_imprint_a_periodic_contrast_grid_at_close_zoom() {
let (w, h) = (256, 256);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for (scale, period) in [(0.25, 4), (0.125, 8)] {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, center: Point::new(32.0, 32.0), ..space(w, h) });
// Bin by location within a document-unit lattice cell. An axis-aligned value field
// puts stronger noise at corners than between them; rotated normalised fields do not.
let mut deviations = Vec::new();
for dy in 0..period {
for dx in 0..period {
let values = (dy..h).step_by(period).flat_map(|y| (dx..w).step_by(period).map(move |x| (x, y)));
deviations.push(film_grain_deviation(values.map(|(x, y)| f64::from(at(&out, w, x, y)[0]))));
}
}
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
let max = deviations.iter().copied().fold(0.0, f64::max);
assert!(max / min < 1.12, "no visible cell-contrast grid at px={scale}: min={min}, max={max}");
}
}
#[test]
fn film_grain_strength_falls_smoothly_across_noninteger_zoom_levels() {
let (w, h) = (128, 96);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let mut previous = f64::INFINITY;
for scale in [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0] {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(
&mut out,
w,
h,
&PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
..space(w, h)
},
);
let deviation = film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
assert!(deviation <= previous * 1.06, "grain must not gain contrast when zooming out at px={scale}: {deviation} after {previous}");
previous = deviation;
}
}
#[test]
fn film_grain_leaves_invalid_coordinate_spaces_alone() {
let (w, h) = (8, 8);
let src = image(w, h, |_, _| grey(128));
for invalid in [
PixelSpace { px: 0.0, ..space(w, h) },
PixelSpace { px: f64::NAN, ..space(w, h) },
PixelSpace { to_doc: Affine::scale(0.0), ..space(w, h) },
PixelSpace { center: Point::new(f64::INFINITY, 0.0), ..space(w, h) },
] {
let mut out = src.clone();
fx("artistic.filmGrain", json!({})).apply(&mut out, w, h, &invalid);
assert_eq!(out, src);
}
let mut out = src.clone();
PixelFx::FilmGrain { grain: f64::NAN, highlight: 0.0, intensity: 10.0 }.apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src, "directly constructed non-finite parameters are harmless too");
}
#[test]
fn params_take_defaults_and_stay_in_range() {
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
+70 -6
View File
@@ -189,6 +189,18 @@ pub(super) fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
/// Strict distance ordering, with the original hypot comparison at rounding/tie boundaries.
fn distance_less(a2: f64, b2: f64, exact: impl FnOnce() -> (f64, f64)) -> bool {
// Cell distances are small and finite. Preserve hypot's ordering at rounding/tie
// boundaries, without computing it for every candidate of every pixel.
if b2.is_finite() && (a2 - b2).abs() <= 8.0 * f64::EPSILON * (a2 + b2) {
let (a, b) = exact();
a < b
} else {
a2 < b2
}
}
/// The two random points nearest to (`u`, `v`) (in cells), `point(i, j)` being cell (`i`, `j`)'s,
/// found among the cells [`AROUND`] the one holding it.
fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
@@ -196,15 +208,17 @@ fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i
let mut best: [(f64, Option<Point>); 2] = [(f64::INFINITY, None); 2];
for (k, (di, dj)) in AROUND.iter().enumerate() {
// A cell farther than the second point so far can't hold a nearer one.
if gap(i.wrapping_add(*di), u).hypot(gap(j.wrapping_add(*dj), v)) >= best[1].0 {
let (gx, gy) = (gap(i.wrapping_add(*di), u), gap(j.wrapping_add(*dj), v));
let exact = |p: Option<Point>| p.map_or(f64::INFINITY, |p| (p.x - u).hypot(p.y - v));
if !distance_less(gx * gx + gy * gy, best[1].0, || (gx.hypot(gy), exact(best[1].1))) {
continue;
}
let Some(p) = around.get((i, j), k, &point) else { continue };
let d = (p.x - u).hypot(p.y - v);
if d < best[0].0 {
best = [(d, Some(p)), best[0]];
} else if d < best[1].0 {
best[1] = (d, Some(p));
let d2 = (p.x - u) * (p.x - u) + (p.y - v) * (p.y - v);
if distance_less(d2, best[0].0, || (exact(Some(p)), exact(best[0].1))) {
best = [(d2, Some(p)), best[0]];
} else if distance_less(d2, best[1].0, || (exact(Some(p)), exact(best[1].1))) {
best[1] = (d2, Some(p));
}
}
Some((best[0].1?, best[1].1?))
@@ -574,3 +588,53 @@ pub(super) fn texturizer(
*p = premultiply(lit(straight(*p), strength * (facing(slope, toward) + ridge)), f32::from(p[3]) / 255.0);
});
}
#[cfg(test)]
mod nearest_tests {
use super::*;
/// Compare with all 25 candidates, without the production distance-pruning shortcut.
fn brute_force(u: f64, v: f64, point: &impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
let (i, j) = (cell(u)?, cell(v)?);
let mut candidates: Vec<_> = AROUND
.iter()
.map(|(di, dj)| {
let p = point(i.wrapping_add(*di), j.wrapping_add(*dj));
((p.x - u).hypot(p.y - v), p)
})
.collect();
candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
Some((candidates.first()?.1, candidates.get(1)?.1))
}
#[test]
fn nearest_points_match_all_candidates_at_cell_and_rounding_boundaries() {
let mut queries = vec![];
for y in -20..=20 {
for x in -24..=24 {
queries.push((f64::from(x) / 13.0, f64::from(y) / 11.0));
}
}
queries.extend([
(0.0, 0.0),
(0.5, 0.5),
(1.0 - f64::EPSILON, -1.0),
(1.0 + f64::EPSILON, -1.0 - f64::EPSILON),
(4.0e6, -4.0e6),
(1.0e14 + 0.25, -1.0e14 + 0.5),
(f64::INFINITY, 0.0),
(f64::NAN, 0.0),
]);
for pattern in 0..3 {
let point = |i: i64, j: i64| match pattern {
0 => point_in(SALT_CRACKS, i, j),
1 => Point::new(i as f64 + 0.5, j as f64 + 0.5),
_ => Point::new(i as f64, j as f64),
};
let mut around = Around::new();
for &(u, v) in &queries {
assert_eq!(nearest_two(u, v, &mut around, point), brute_force(u, v, &point), "pattern {pattern} at ({u}, {v})");
}
}
}
}
+3 -3
View File
@@ -92,7 +92,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
art.isolate = false;
// One element: its pieces never knock each other out.
art.knockout = vectorcraft_doc::Knockout::Off;
art.mask = None;
art.extras_mut().mask = None;
art.appearance.effects.clear();
Some(art)
}
@@ -141,7 +141,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) };
let image = Node::new(n.id, n.kind.clone());
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
}
_ => outline_art(n, symbol_art)?,
@@ -159,7 +159,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
out.isolate = n.isolate;
// One element: its pieces never knock each other out.
out.knockout = vectorcraft_doc::Knockout::Off;
out.mask = n.mask.clone();
out.extras_mut().mask = n.extras().mask.clone();
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(out)
}
+1 -1
View File
@@ -200,7 +200,7 @@ fn art(n: &Node, expand: bool) -> Option<Node> {
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
+67 -30
View File
@@ -318,7 +318,8 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
/// Nothing targeted is not an edit.
/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and
/// its undo history stay as they are.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
@@ -328,10 +329,12 @@ pub(crate) fn edit_items(
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(()))
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false))
}
/// [`edit_items`], then `after` on the ids `f` changed, inside the same undo step.
/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns
/// whether it changed the document; when it did not and `f` left every target as it was, the
/// document and its undo history stay as they are.
#[allow(clippy::too_many_arguments)]
pub(crate) fn edit_items_then(
s: &mut Session,
@@ -341,21 +344,29 @@ pub(crate) fn edit_items_then(
label: &str,
fill: bool,
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<()>,
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
s.edit(label, |d, _| {
let mut changed = Vec::new();
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, label, |d| {
let mut edited = Vec::new();
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
let index = item.resolve(&n.appearance, fill, cmd)?;
f(n, index)?;
changed.push(*id);
edited.push(*id);
}
after(d, &changed)
})
let more = after(d, &edited)?;
Ok(count_changed(d, &before, &edited) + usize::from(more))
})?;
Ok(())
}
/// How many of `ids` differ in `d` from `before`.
pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_doc::Document, ids: &[NodeId]) -> usize {
ids.iter().filter(|id| d.node(**id) != before.node(**id)).count()
}
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
@@ -428,13 +439,15 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let ids = require_targets(s, p, "appearance.clear")?;
s.edit("Clear Appearance", |d, _| {
// When every object is cleared already, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Clear Appearance", |d| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
clear(n);
}
}
Ok(())
Ok(count_changed(d, &before, &ids))
})?;
s.active_appearance_item = None;
ok()
@@ -443,7 +456,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
/// group keeps no fill or stroke rows of its own.
fn clear(n: &mut Node) {
n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) };
n.appearance = if is_group(n) { Appearance::default().into() } else { Appearance::basic(Paint::None, Paint::None, 1.0).into() };
n.opacity = 1.0;
n.blend = BlendMode::Normal;
}
@@ -456,7 +469,9 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`source` and `target` are the same object"));
}
let copy = bool_or(p, "copy", false);
s.edit(if copy { "Copy Appearance" } else { "Move Appearance" }, |d, _| {
// When both objects come out as they were, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
let mut ap = src.appearance.clone();
@@ -473,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
}
Ok(())
Ok(count_changed(d, &before, &[source, target]))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
@@ -486,7 +501,9 @@ fn is_group(n: &Node) -> bool {
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
s.edit("Reduce to Basic Appearance", |d, _| {
// When every object comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
// The topmost visible fill and stroke stay, without their own transparency or effects.
@@ -496,7 +513,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
// keeps the rows it had.
let fill =
(fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone()))));
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() };
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() }.into();
if let Some(AppearanceItem::Stroke(mut st)) = stroke {
st.effects.clear();
st.opacity = 1.0;
@@ -505,7 +522,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(())
Ok(count_changed(d, &before, &ids))
})?;
s.active_appearance_item = None;
ok()
@@ -519,9 +536,17 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("appearance.setItem", "select objects or give ids"));
}
let blend = str_param(p, "blend").and_then(BlendMode::parse);
s.edit("Appearance", |d, _| {
// When every object comes out as it was (for example, when none has item `index`), the document
// and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Appearance", |d| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if p.get("weight").is_none()
&& let Some(pa) = &paint
{
super::paint::restore_unpainted_stroke(&mut n.appearance, Some(idx), pa);
}
let bounds = n.geometric_bounds();
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
let (pp, op, bl, vis, bounds) = match item {
@@ -547,7 +572,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
*vis = v;
}
}
Ok(())
Ok(count_changed(d, &before, &ids))
})?;
if let Some(pa) = &paint {
s.remember_paint(pa);
@@ -574,7 +599,10 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = item_indices(p, "appearance.removeItem")?;
let ids = require_targets(s, p, "appearance.removeItem")?;
s.edit("Remove Item", |d, _| {
// When no object has an item at any of the indices, the document and its undo history stay as
// they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Remove Item", |d| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
// From the top down, so the lower indices stay valid.
@@ -583,7 +611,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(())
Ok(count_changed(d, &before, &ids))
})?;
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
ok()
@@ -648,7 +676,10 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
let from = index_param(p, "from", C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
let mut landed = to;
s.edit("Reorder Appearance", |d, _| {
// When every object's stack comes out as it was, the document and its undo history stay as they
// are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?;
@@ -656,7 +687,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
n.appearance.set_contents_at(k);
}
}
Ok(())
Ok(count_changed(d, &before, &ids))
})?;
// The moved row stays the active one; the rows it passed shift by one.
s.remap_appearance_item(&ids, |a| {
@@ -676,14 +707,18 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
/// has one above its bottom `to` items.
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
let slot = s.edit("Reorder Appearance", |d, _| {
let mut slot = None;
// When every Contents row is above the bottom `to` items already, the document and its undo
// history stay as they are.
let before = s.doc()?.doc.clone();
let mut slot = None;
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
for id in ids {
let Some(n) = d.node_mut(*id).filter(|n| n.contents_label().is_some()) else { continue };
n.appearance.set_contents_at(to);
slot.get_or_insert(n.appearance.contents_at());
}
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
Ok(count_changed(d, &before, ids))
})?;
Ok(json!({ "contents": slot }))
}
@@ -728,13 +763,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
if targets.is_empty() || attrs.is_empty() {
return Ok(json!({ "ids": [] }));
}
s.edit("Eyedropper", |d, _| {
// When every target comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
for id in &targets {
if let Some(n) = d.node_mut(*id) {
picked.apply(n, &attrs, append);
}
}
Ok(())
Ok(count_changed(d, &before, &targets))
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
@@ -758,7 +795,7 @@ impl Picked {
let st = &t.runs[0].style;
(st.basic_appearance(), Some(t.local_bounds()), Some((st.clone(), t.para.clone())))
}
_ => (n.appearance.clone(), n.geometric_bounds(), None),
_ => ((*n.appearance).clone(), n.geometric_bounds(), None),
};
Self { appearance, bounds, opacity: n.opacity, blend: n.blend, text }
}
@@ -831,7 +868,7 @@ impl Picked {
}
let placed = self.placed(n.geometric_bounds());
if ap.fill == FillAttrs::ALL && ap.stroke == StrokeAttrs::ALL {
n.appearance = placed;
n.appearance = placed.into();
} else {
focal_attrs(&mut n.appearance, &placed, ap);
}
+15 -5
View File
@@ -69,16 +69,26 @@ fn ruled<'a>(n: &'a Node, out: &mut Vec<(&'a Node, FillRule)>) {
}
}
/// A directly selected compound member fills with its compound's rule. Its other attributes
/// and path direction still belong to the member itself.
fn fill_rule_owner(d: &Document, id: NodeId) -> Option<&Node> {
let n = d.node(id)?;
match d.parent_of(id).and_then(|parent| d.node(parent)) {
Some(parent) if matches!(parent.kind, NodeKind::Compound { .. }) => Some(parent),
_ => Some(n),
}
}
/// The Attributes panel's values of `info.ids` beyond overprinting.
pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) {
let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect();
let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default());
let attrs = || nodes.iter().map(|n| n.extras().attrs.as_deref().cloned().unwrap_or_default());
info.show_center = same(nodes.iter().map(|n| n.shows_center()));
info.image_map = same(attrs().map(|a| a.image_map));
info.url = same(attrs().map(|a| a.url));
info.note = same(attrs().map(|a| a.note));
let mut all = vec![];
nodes.iter().for_each(|n| ruled(n, &mut all));
nodes.iter().filter_map(|n| fill_rule_owner(d, n.id)).for_each(|n| ruled(n, &mut all));
info.fill_rule = same(all.into_iter().map(|(_, r)| r));
let paths = paths_in(d, &info.ids);
let subpaths = paths.iter().filter_map(|id| d.node(*id)?.path_data()).flat_map(|p| &p.subpaths);
@@ -116,7 +126,7 @@ impl Edits {
/// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default).
fn apply(&self, n: &Node) -> ObjectAttributes {
let mut a = n.attrs.as_deref().cloned().unwrap_or_default();
let mut a = n.extras().attrs.as_deref().cloned().unwrap_or_default();
if let Some(v) = self.show_center {
a.show_center = (v != n.shows_center_by_default()).then_some(v);
}
@@ -148,7 +158,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
let Some(n) = d.node(*id) else { continue };
let new = edits.apply(n);
if new != n.attrs.as_deref().cloned().unwrap_or_default()
if new != n.extras().attrs.as_deref().cloned().unwrap_or_default()
&& let Some(n) = d.node_mut(*id)
{
n.edit_attrs(|a| *a = new);
@@ -172,7 +182,7 @@ fn set_fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let changed = edit_counted(s, "Fill Rule", |d| {
let mut all = vec![];
ids.iter().filter_map(|id| d.node(*id)).for_each(|n| ruled(n, &mut all));
ids.iter().filter_map(|id| fill_rule_owner(d, *id)).for_each(|n| ruled(n, &mut all));
let todo: BTreeSet<_> = all.into_iter().filter(|(_, r)| *r != rule).map(|(n, _)| n.id).collect();
for id in &todo {
if let Some(NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
+1 -1
View File
@@ -302,7 +302,7 @@ fn brush_set_current(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The selection as one art node (a group for several objects), ids kept.
fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
pub(crate) fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
let d = &s.doc()?.doc;
let ids = roots(s, p)?;
let nodes: Vec<Node> = ids.iter().filter_map(|id| d.node(*id).cloned()).collect();
+12 -10
View File
@@ -478,7 +478,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), e.appearance.clone()))
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
})
.collect();
let mut sources = source_nodes(&old);
@@ -505,7 +505,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app;
Arc::make_mut(e).appearance = app.into();
}
}
for r in roots.iter().filter(|r| **r != target) {
@@ -553,7 +553,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
n.name = None;
n.trace = None;
n.extras_mut().trace = None;
if let Some(ch) = n.children_mut() {
ch.retain(|c| match c.name.as_deref() {
Some(SOURCES_NAME) => false,
@@ -734,7 +734,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
.ok_or_else(|| bad(C, "select an image (or an Image Trace group)"))?
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
// An image over the tracer's size is traced scaled down (the paths are scaled back below).
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let library = trace_library(s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
// Pixel space of the decoded raster → the image object's pixel space → document.
@@ -777,8 +778,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
..StrokeLayer::new(paint, width * unit)
})],
..Default::default()
},
None => fill_only(paint),
}
.into(),
None => fill_only(paint).into(),
};
children.push(Arc::new(n));
}
@@ -788,7 +790,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
if !expand {
// Tracing again keeps the object's view unless another is asked for.
let view = view.unwrap_or_else(|| src.trace_view());
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() })));
g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() })));
}
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
d.remove(target)?;
@@ -819,10 +821,10 @@ fn trace_set_view(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Image Trace View", |d, _| {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) {
if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) {
t.insert("view".into(), json!(view.id()));
} else {
n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() })));
n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() })));
}
}
Ok(())
@@ -852,7 +854,7 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
n.name = None;
n.trace = None;
n.extras_mut().trace = None;
if let Some(ch) = n.children_mut() {
ch.retain(|c| !matches!(c.kind, NodeKind::Image(_)));
}
+2 -2
View File
@@ -142,8 +142,8 @@ impl Clipboard {
d.patterns = self.patterns.clone();
d.swatches.extend(self.swatches.iter().cloned());
d.graphic_styles.extend(self.graphic_styles.iter().cloned());
d.char_styles = self.char_styles.clone();
d.para_styles = self.para_styles.clone();
d.char_styles = self.char_styles.clone().into();
d.para_styles = self.para_styles.clone().into();
if !self.brushes.is_empty() {
let mut lib = vectorcraft_brush::library(&d);
for b in &self.brushes {
+1 -1
View File
@@ -315,7 +315,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (img.width, img.height);
let image =
ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() };
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() };
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() };
clip.images.insert(img.key, img.blob);
let count = s.load_clipboard(clip, p)?;
Ok(json!({ "count": count, "width": w as f64 * sx, "height": h as f64 * sy, "pixelWidth": w, "pixelHeight": h }))
+4 -4
View File
@@ -107,10 +107,10 @@ fn attrs_refs(attrs: &Map<String, Value>, f: Visit) {
fn node_refs(n: &Node, f: Visit) {
n.walk(&mut |m| {
appearance_refs(&m.appearance, f);
if let Some(id) = m.graphic_style {
if let Some(id) = m.extras().graphic_style {
f(Ref::Style(id));
}
if let Some(mask) = &m.mask {
if let Some(mask) = &m.extras().mask {
node_refs(&mask.art, f);
}
match &m.kind {
@@ -427,8 +427,8 @@ impl Relink {
/// Relink a node tree (the mirror of [`node_refs`]).
fn node(&self, n: &mut Node) {
self.appearance(&mut n.appearance);
n.graphic_style = n.graphic_style.and_then(|id| self.styles.get(&id).copied());
if let Some(m) = &mut n.mask {
n.extras_mut().graphic_style = n.extras().graphic_style.and_then(|id| self.styles.get(&id).copied());
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
self.node(Arc::make_mut(&mut m.art));
}
match &mut n.kind {
+2 -2
View File
@@ -97,7 +97,7 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
let (par, idx, _) = d.position(target).ok_or(crate::EngineError::NoNode(target))?;
let mut mask = d.node(target).cloned().ok_or(crate::EngineError::NoNode(target))?;
mask.id = d.alloc_id();
mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0).into();
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
*clipping = true;
}
@@ -157,7 +157,7 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result<Value> {
}
fn path_kind(path: PathData, live: Option<LiveShape>) -> NodeKind {
NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false }
NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false }
}
fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
+154 -31
View File
@@ -266,6 +266,17 @@ fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f6
}
}
/// Whether `a` and `b` hold the same width points, each place and width within a rounding error
/// (1e-9).
fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
let near = |p: &(f64, f64, f64), q: &(f64, f64, f64)| (p.0 - q.0).abs() <= 1e-9 && (p.1 - q.1).abs() <= 1e-9 && (p.2 - q.2).abs() <= 1e-9;
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a.points.len() == b.points.len() && a.points.iter().zip(&b.points).all(|(p, q)| near(p, q)),
_ => false,
}
}
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.set";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
@@ -275,12 +286,16 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "left/right must be non-negative widths"));
}
let adjust = bool_or(p, "adjustAdjoining", false);
let pos = s.edit("Width Point", |d, _| {
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
// history stay as they are.
let mut pos = 0;
super::overprint::edit_counted(s, "Width Point", |d| {
let st = weighted_stroke(d, id)?;
let before = st.profile.clone();
let half = st.width / 2.0;
let mut prof = st.profile.take().unwrap_or_else(uniform);
let pt = (t, left / half, right / half);
let pos = match point_index(p, &prof, C)? {
pos = match point_index(p, &prof, C)? {
Some(i) => {
let old = prof.points[i];
if adjust {
@@ -301,7 +316,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
}
};
st.profile = Some(prof);
Ok(pos)
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
})?;
Ok(json!({ "index": pos }))
}
@@ -310,14 +325,18 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.copy";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
let pos = s.edit("Copy Width Point", |d, _| {
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
// its widths already), the document and its undo history stay as they are.
let mut pos = 0;
super::overprint::edit_counted(s, "Copy Width Point", |d| {
let st = weighted_stroke(d, id)?;
let before = st.profile.clone();
let mut prof = st.profile.take().unwrap_or_else(uniform);
let i = point_index(p, &prof, C)?.ok_or_else(|| bad(C, "missing index"))?;
let (from, l, r) = prof.points[i];
let pos = place(&mut prof.points, (t, l, r), from < t);
pos = place(&mut prof.points, (t, l, r), from < t);
st.profile = Some(prof);
Ok(pos)
Ok(usize::from(st.profile != before))
})?;
Ok(json!({ "index": pos }))
}
@@ -373,6 +392,11 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
ids.sort_unstable();
ids.dedup();
// When every stroke has the profile already, or there is no stroke, the document and its undo
// history stay as they are.
if ids.iter().all(|id| doc.node(*id).is_some_and(|n| n.appearance.stroke().is_none_or(|st| st.profile == profile))) {
return ok();
}
s.edit("Width Profile", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -415,7 +439,7 @@ struct Targets {
/// What `n` is when Liquify can't distort it (and doesn't look inside it).
fn not_liquified(n: &Node) -> Option<&'static str> {
Some(match &n.kind {
NodeKind::Group { .. } if n.graph.is_some() => "graphs",
NodeKind::Group { .. } if n.extras().graph.is_some() => "graphs",
NodeKind::Text(_) => "type",
NodeKind::SymbolInstance { .. } => "symbols",
NodeKind::Image(_) => "images",
@@ -602,18 +626,23 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
stroke.extend(pts.get(done..).unwrap_or_default());
let results = stroke.results();
let (skipped, warning) = stroke.skipped();
let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
let mut changed = vec![];
for (id, new) in results {
let Some(n) = d.node_mut(id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = new;
*live = None;
changed.push(id.0);
// When the stroke reshapes no path, the document and its undo history stay as they are.
let changed = if results.is_empty() {
vec![]
} else {
s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
let mut changed = vec![];
for (id, new) in results {
let Some(n) = d.node_mut(id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = new;
*live = None;
changed.push(id.0);
}
}
}
Ok(changed)
})?;
Ok(changed)
})?
};
if s.in_interaction() {
s.liquify_stroke = Some(stroke);
}
@@ -659,10 +688,21 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
if rest {
let pins: Vec<PuppetPin> = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect();
s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?;
// When the art and its pins come out as they were, the document and its undo history stay
// as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Puppet Warp", |d| {
warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e))?;
Ok(usize::from(d.puppet != before.puppet || ids.iter().any(|id| d.node(*id) != before.node(*id))))
})?;
return Ok(ids_json);
}
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
// When every pin stays where it is and turns the art by 0° or is free, the document and its undo
// history stay as they are.
if pins == moved && angles.iter().all(|a| a.is_none_or(|a| a == 0.0)) {
return Ok(ids_json);
}
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect();
let deformed = arap::deform(&mesh, &pins);
let f = |q: Point| mesh.map(&deformed, q);
@@ -707,6 +747,14 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
g.store(d);
}
/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g`
/// leaves `d` as it is.
fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
let mut g = g.clone();
g.attached.clear();
serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v))
}
/// Change view state of the grid without an undo step.
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
let st = s.doc_mut()?;
@@ -724,6 +772,11 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
let old = grid_of(&s.doc()?.doc);
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
// When the document holds this grid already, no object moves, and the document and its undo
// history stay as they are.
if grid_kept(&s.doc()?.doc, &g) {
return Ok(g.definition_json());
}
s.edit("Define Perspective Grid", |d, _| {
if reproject {
old.adopt_stored_attachments(d);
@@ -800,6 +853,11 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, "perspective.attach")?;
// With no object to attach, when the document holds the grid already, the document and its undo
// history stay as they are.
if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) {
return Ok(json!({ "ids": [], "plane": plane.id() }));
}
s.edit("Attach to Active Plane", |d, _| {
for id in &ids {
attach_in(d, &mut g, *id, plane, false, None)?;
@@ -813,6 +871,19 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
// When releasing leaves every object as it is (none is in perspective) and the document holds
// the grid already, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
let unchanged = |id: &NodeId| {
d.node(*id).is_none_or(|n| {
let mut released = n.clone();
persp::release(&mut released);
released == *n
})
};
if ids.iter().all(unchanged) && grid_kept(d, &g) {
return ok();
}
s.edit("Release with Perspective", |d, _| {
for id in &ids {
g.attached.remove(&id.0.to_string());
@@ -875,6 +946,19 @@ fn persp_edit(
})
}
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
/// of them is in perspective already, `still` holds for each one's plane and depth, and the document
/// holds the grid already; None otherwise.
fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result<Option<Attached>> {
let d = &s.doc()?.doc;
let g = grid_of(d);
if ids.is_empty() || !grid_kept(d, &g) {
return Ok(None);
}
let at: Option<Vec<Attached>> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect();
Ok(at.and_then(|at| at.first().copied()))
}
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
/// the drag that made it is committed.
fn record_again(s: &mut Session, again: Value) -> Result<()> {
@@ -921,11 +1005,25 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
// Transform Again repeats the first object's move.
// When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid
// included) and there is no copy, the document and its undo history stay as they are.
let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| {
if perp {
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
} else {
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
}
};
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
let (targets, first) = match first_still {
Some(at) => (ids, Some(at)),
None => persp_edit(s, label, &ids, fallback, copy, &step)?,
};
// Transform Again repeats the first object's move, and the identity when no object moved.
if let Some((pl, depth)) = first {
let g = grid_of(&s.doc()?.doc);
let again = match perp {
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
@@ -955,7 +1053,14 @@ fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
};
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
// An identity without a copy moves no object that is in perspective already: then the document
// and its undo history stay as they are, and Transform Again repeats the identity.
let identity = !copy && m == Affine::IDENTITY && dz == 0.0;
let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() {
ids
} else {
persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0
};
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
Ok(ids_json(&targets))
}
@@ -992,16 +1097,25 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
"move" => "Move Plane and Objects",
_ => "Move Plane",
};
// The objects on the plane where it was (not those moved off it along its normal).
let on_plane = |d: &Document| -> Vec<NodeId> {
persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.map(|(id, ..)| id)
.collect()
};
// A plane moved to where it is, with its objects or without them, moves nothing: when the
// document holds the grid already, the document and its undo history stay as they are.
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) {
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
}
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
// The objects on the plane where it was (not those moved off it along its normal).
let on: Vec<NodeId> = persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.map(|(id, ..)| id)
.collect();
let on = on_plane(d);
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
for id in &ids {
@@ -1025,6 +1139,11 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
g.set_offset(plane, depth);
g.plane = plane;
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
// When the plane is at the object and active already, and the document holds that grid, the
// document and its undo history stay as they are.
if grid_kept(&st.doc, &g) {
return Ok(json!({ "plane": plane.id(), "offset": depth }));
}
s.edit("Move Plane to Match Object", |d, _| {
store_grid(d, &g);
Ok(())
@@ -1079,6 +1198,7 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = st.history.undo.last_mut() {
e.label = "Draw in Perspective".into();
}
st.history.recost_last(&st.doc);
}
res?;
Ok(json!({ "id": id.0, "plane": plane.id() }))
@@ -1132,7 +1252,7 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) };
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
t.transform(shift);
if let Some(rec) = n.perspective.as_deref_mut() {
if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) {
rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array());
rec.editing = true;
}
@@ -1149,8 +1269,10 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
/// in every undo state (the flat view is never an undo step of its own).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut())
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
// builds (the document now keeps its own).
if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()))
{
p.editing = false;
}
@@ -1162,6 +1284,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
}
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
clear(&mut e.doc);
e.doc.drop_lookup_cache();
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);
+1 -1
View File
@@ -290,7 +290,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
spot.insert(name.clone());
}
}
if n.mask.is_some() {
if n.extras().mask.is_some() {
*counts.entry("opacityMasks").or_default() += 1;
}
if !n.appearance.effects.is_empty() {
+21 -14
View File
@@ -10,6 +10,7 @@ use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection,
use vectorcraft_geom::Vec2;
use super::edit::selected_roots;
use super::select::{selectable_ids, walk_selectable};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -57,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Direction Handles",
["Select", "Object"],
None,
"{} direct-select every anchor (showing all handles) of the selected paths → {anchors}",
"{} direct-select every anchor (showing all handles) of the selected paths and the paths inside the selected objects, leaving out paths that are hidden or locked, inside a hidden or locked object or layer, on a template layer, or outside the isolated group or layer → {anchors}",
has_selection,
direction_handles
),
@@ -103,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recall Selection",
["Select"],
None,
"{name} select the objects of a saved selection (those since deleted are left out) → {count}",
"{name} select the objects of a saved selection, leaving out those deleted since and those that are hidden or locked, inside a hidden or locked object or layer, on a template layer, or outside the isolated group or layer → {count}",
has_saved_selection,
recall_selection
),
@@ -243,13 +244,12 @@ impl Session {
fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if !n.is_container() && n.visible && !n.locked && f(n) {
ids.push(n.id);
}
});
}
walk_selectable(&st.doc, st.isolation, |n| {
if !n.is_container() && f(n) {
ids.push(n.id);
}
true
});
Ok(ids)
}
@@ -304,12 +304,17 @@ fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let st = s.doc()?;
let d = &st.doc;
// Only paths that `walk_selectable` visits.
let can = selectable_ids(d, st.isolation);
let mut items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = vec![];
for r in &roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
if can.contains(&c.id)
&& let Some(p) = c.path_data()
{
items.push((c.id, p.anchors().map(|(si, ai, _)| (si, ai)).collect()));
}
});
@@ -352,7 +357,9 @@ fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?;
let st = s.doc()?;
let can = selectable_ids(&st.doc, st.isolation);
s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?;
Ok(json!({ "count": s.doc()?.selection.len() }))
}
@@ -395,9 +402,9 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("two saved selections would be named `{}`", x.name)));
}
}
if after != before {
if *before != after {
s.edit("Edit Selection", |d, _| {
d.saved_selections = after;
d.saved_selections = after.into();
Ok(())
})?;
}
+1
View File
@@ -781,6 +781,7 @@ fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors:
sel.remove(id);
} else {
sel.anchors.remove(&id);
sel.segments.remove(&id);
}
Ok(gone)
}
+87 -22
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_geom::{Affine, Vec2};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
use super::*;
@@ -37,6 +37,25 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_units
),
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
cmd!(
"document.setRulerOrigin",
"Ruler Origin",
[],
None,
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
has_doc,
set_ruler_origin
),
cmd!(
"document.resetRulerOrigin",
"Reset Ruler Origin",
[],
None,
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
has_doc,
reset_ruler_origin
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
@@ -180,6 +199,35 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.setRulerOrigin";
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
change_ruler_origin(s, origin, "Ruler Origin")
}
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.resetRulerOrigin";
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
};
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
}
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
}
if s.doc()?.doc.ruler_origin != origin {
s.edit(label, |d, _| {
d.ruler_origin = origin;
Ok(())
})?;
}
Ok(json!(origin))
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).
pub(crate) fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
@@ -214,12 +262,13 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
let costs = e.costs();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.redo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.redo.last());
st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
@@ -230,18 +279,27 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
let costs = e.costs();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.undo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.undo.last());
st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
match like {
Some(l) if *l.selection == selection => l.selection.clone(),
_ => Arc::new(selection),
}
}
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
@@ -251,22 +309,29 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
let paths = doc.index_paths(ids.iter().copied());
// A compound-path member acts as its compound (compounds are one object).
let mut sel: Vec<NodeId> = ids
let mut sel: Vec<(NodeId, Option<Vec<usize>>)> = ids
.into_iter()
.map(|id| match doc.parent_of(id).and_then(|p| doc.node(p).map(|n| (p, n))) {
Some((p, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => p,
_ => id,
.map(|id| {
let path = paths.get(&id);
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())),
_ => (id, path.cloned()),
}
})
.collect();
sel.dedup();
sel.dedup_by_key(|(id, _)| *id);
let listed: std::collections::HashSet<NodeId> = sel.iter().map(|(id, _)| *id).collect();
sel.iter()
.copied()
.filter(|id| {
let anc = doc.ancestry(*id).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a))
.filter(|(_, path)| {
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
})
.filter(|id| doc.node(*id).is_some_and(|n| !n.is_layer()))
.filter(|(_, path)| path.as_deref().and_then(|p| doc.node_at(p)).is_some_and(|n| !n.is_layer()))
.map(|(id, _)| *id)
.collect()
}
@@ -293,7 +358,7 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
// Direct-selected anchors: delete those anchors instead of whole objects.
// Direct-selected anchors or segments: remove those components instead of whole objects.
if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {
return super::path::delete_anchors(s, p);
}
+5 -5
View File
@@ -236,13 +236,13 @@ impl Builder {
let appearance = self.appearance(fills, strokes, to_doc);
if children.is_empty() {
let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false });
p.appearance = appearance;
p.appearance = appearance.into();
p
} else {
// A shape clips its children: VectorCraft's clipping path keeps its paint, its
// fill behind the clipped art and its stroke over it, as Affinity draws it.
let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false });
clip.appearance = appearance;
clip.appearance = appearance.into();
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
self.base(n, NodeKind::Group { children: kids, clip: true })
@@ -259,7 +259,7 @@ impl Builder {
guide: false,
},
);
clip.appearance = self.appearance(background, strokes, to_doc);
clip.appearance = self.appearance(background, strokes, to_doc).into();
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
let mut g = self.base(n, NodeKind::Group { children: kids, clip: true });
@@ -289,7 +289,7 @@ impl Builder {
// shows outside it, as an opacity mask that clips.
if let Some(kind) = self.image(img, to_doc) {
let art = Node::new(self.doc.alloc_id(), kind);
out.mask = Some(Box::new(OpacityMask::new(art, true)));
out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true)));
}
}
if let Some(mask) = &n.mask {
@@ -514,7 +514,7 @@ impl Builder {
let sy = img.height as f64 / f64::from(r.height.max(1));
let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy);
self.doc.images.entry(r.key.clone()).or_insert(r.blob);
Some(NodeKind::Image(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))
Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() })))
}
}
+8 -12
View File
@@ -293,9 +293,9 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result<AntiAlias, String> {
AntiAlias::from_id(id).ok_or_else(|| format!("antiAlias `{id}`: none, art or type"))
}
/// The `useArtboards` param of a PDF, DXF or raster export (SVG has its own): `true` writes every
/// The `useArtboards` param of a PDF, AI, DXF or raster export (SVG has its own): `true` writes every
/// chosen artboard (default all), `false` the bounds of the visible art instead, absent one
/// artboard (raster, DXF) or the chosen pages (PDF).
/// artboard (raster, DXF) or the chosen pages (PDF, AI).
fn use_artboards(p: &Value) -> Result<Option<bool>> {
match p.get("useArtboards") {
None | Some(Value::Null) => Ok(None),
@@ -364,9 +364,10 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(
/// [`encode`], with every file the export writes.
pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
// Placed documents output their files' art (a native file keeps what it keeps: see
// `native::encode`).
if !matches!(format, "vectorcraft" | "template") {
let format = super::writable(C, Some(format), None)?.id;
// Formats with editing data prepare their drawn output separately from their original
// document. Other formats only need the placed files' art.
if !matches!(format, "vectorcraft" | "template" | "ai" | "pdf" | "eps" | "svg" | "svgz") {
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
if !warnings.is_empty() {
let mut enc = encode_all_of(&full, format, p)?;
@@ -397,7 +398,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
@@ -417,16 +418,11 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
"txt" => super::text::encode(doc, p)?,
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
"dxf" => return super::dxf::encode(doc, p, use_artboards),
// As Save As writes it (a PDF of every artboard carrying the native document).
"ai" => {
let mut q = p.clone();
super::save::ai_params(&mut q);
return super::save::encode_ai(C, f, doc, &q);
}
// EPS reads its own `useArtboards` (the art's bounds unless asked).
"eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
"ai" => return super::save::encode_ai(C, f, doc, p),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
+20 -3
View File
@@ -255,6 +255,17 @@ fn native(doc: &Document, board: Option<usize>, include_linked: bool) -> Vec<u8>
/// itself.
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
let set = parse(p)?;
let original = doc;
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
let doc = &*full;
// Include available bytes while retaining missing, relinkable objects in the attachment.
let editing = if set.include_linked {
let mut d = original.clone();
d.images = doc.images.clone();
Cow::Owned(d)
} else {
Cow::Borrowed(original)
};
let pages = pages(doc, &set)?;
if pages.is_empty() {
return Err(bad(C, "the document has no artboard"));
@@ -262,7 +273,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
let transparent = doc.layers.iter().any(|l| l.shows_transparency());
let flat = flatten_document(doc, &set.flatten)?;
let drawn = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded { joiner: Some("_"), ..Encoded::default() };
let mut enc = Encoded { warnings, joiner: Some("_"), ..Encoded::default() };
if transparent && flat.is_some() {
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
}
@@ -275,8 +286,14 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
kind => Some(render(&mut renderer, doc, rect, MAX_PREVIEW, set.eps.transparent_preview && kind == Preview::TiffColor)?),
};
let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?;
let o =
EpsOptions { region, origin, title: doc.title.clone(), created, native: Some(native(doc, board, set.include_linked)), ..set.eps.clone() };
let o = EpsOptions {
region,
origin,
title: doc.title.clone(),
created,
native: Some(native(&editing, board, set.include_linked)),
..set.eps.clone()
};
let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?;
enc.files.push((board, out.bytes));
for w in out.warnings {
+1 -1
View File
@@ -82,7 +82,7 @@ fn collect(n: &Node, region: Rect, scale: f64, out: &mut Vec<Area>) {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return;
}
let shape = n.attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default();
let shape = n.extras().attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default();
let px = |x: f64, y: f64| [((x - region.x0) * scale).round() as i64, ((y - region.y0) * scale).round() as i64];
if let (Some(href), Some(b)) = (n.url(), n.visual_bounds().filter(|b| b.overlaps(region)))
&& shape != ImageMap::None
+45 -11
View File
@@ -9,7 +9,7 @@ use vectorcraft_geom::Affine;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
use crate::EngineError;
use crate::{EngineError, Prefs};
/// A file read into a document, with its format and non-fatal import notes.
pub struct Loaded {
@@ -26,6 +26,18 @@ pub struct Loaded {
pub preview_only: bool,
}
impl Loaded {
/// The file with the grid of Preferences › Guides & Grid, unless it is a native document (also
/// one that an SVG, PDF, `.ai` or EPS file carries, `restored`), which keeps the grid it was
/// saved with.
pub(crate) fn with_grid_prefs(mut self, prefs: &Prefs) -> Self {
if !self.restored && !matches!(self.format.id, "vectorcraft" | "template") {
self.doc.grid = prefs.grid();
}
self
}
}
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
/// (what browsers, PDF and SVG viewers show).
pub struct RasterImage {
@@ -136,21 +148,24 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
load_with(name, bytes, &LoadOptions::default())
}
/// A PDF-compatible `.ai` read through the editor's own copy of its art, which it carries: its
/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has
/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was.
/// A PDF-compatible `.ai`, or a PDF saved with the editing data, read through the editor's own copy
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
/// Anything else as it was.
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
return (doc, warnings);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
// The note that the art outside the artboard is lost is the reason the layers weren't read.
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings);
}
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE);
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
// to the page.
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
(doc, warnings)
}
@@ -271,8 +286,26 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
open_read(s, read_for_open(name, bytes, p)?, path)
}
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
pub struct ReadFile {
loaded: Loaded,
opts: LoadOptions,
}
/// The first half of [`open_bytes_with`]: read and decode the file, which needs no session (an
/// app may do it on another thread) → what [`open_read`] opens.
pub fn read_for_open(name: &str, bytes: &[u8], p: &Value) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
open_loaded(s, load_with(name, bytes, &opts)?, path, &opts, false)
Ok(ReadFile { loaded: load_with(name, bytes, &opts)?, opts })
}
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document (`path`: where it was read from) → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile, path: Option<String>) -> Result<Value> {
open_loaded(s, file.loaded, path, &file.opts, false)
}
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
@@ -289,7 +322,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
/// Make a loaded file the new active document; `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded;
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
// What reading the file left out of the document, if anything.
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
let source_path = path.clone().filter(|_| !losses.is_empty());
@@ -418,7 +451,8 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
let layer = d.layers[0].id;
let id = d.alloc_id();
let xf = Affine::scale_non_uniform(sx, sy);
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }));
let mut n =
Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() })));
n.name = Some(name.to_string());
d.images.insert(key, blob);
d.insert(Some(layer), 0, n).map_err(err)?;
+117 -6
View File
@@ -53,15 +53,18 @@ use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use load::{
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
read_for_open,
};
pub use load::{losses_summary, overwrite_losses};
pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
templates_dialog_folder, templates_folder,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, export_folder, save_filters, save_format, save_job, save_plan, save_with,
stamp_save_dates, templates_dialog_folder, templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub(crate) use screens::{
@@ -83,10 +86,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS or .ai that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
cmd!(
query "file.list",
"List Folder",
[],
None,
"{path} the files and folders in folder `path`, by name: what an agent may open there (held to the automation read root when one is in force) → {path, entries: [{name, kind: file|folder|link, size? (files, bytes)}], truncated (more than 5000 entries)} (#1163)",
always,
file_list
),
cmd!(
query "document.serialize",
"Serialize Document",
@@ -236,7 +248,7 @@ const ARTBOARDS: FormatOption = FormatOption {
name: "artboards",
ty: "array",
default: "null",
description: "0-based artboards (PDF: one page each, default all; SVG, and raster formats with useArtboards: one file each)",
description: "0-based artboards (PDF and AI: one page each, default all; SVG, and raster formats with useArtboards: one file each)",
};
const RANGE: FormatOption = FormatOption {
name: "range",
@@ -251,7 +263,7 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
name: "useArtboards",
ty: "boolean",
default: "null",
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF: one page each); false: the bounds of the visible art",
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
};
const PPI: FormatOption = FormatOption {
name: "ppi",
@@ -760,6 +772,13 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// [`write_file`] with what `fill` writes into the file (a save streamed into it).
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
may_write(path)?;
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn create_dir(path: &str) -> Result<()> {
may_write(path)?;
@@ -783,6 +802,93 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
})
}
/// The highest number [`copy_new`] gives a copy's name (`Name 99.png`).
#[cfg(not(target_arch = "wasm32"))]
const MAX_COPY_NUMBER: u32 = 99;
/// Where [`copy_new`] put a file.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) enum Placed {
/// Copied to this new file.
Copied(std::path::PathBuf),
/// Kept as this file, which holds the same contents.
Kept(std::path::PathBuf),
}
/// Put a copy of the file `from` into the folder `dir`, never replacing a file there. It tries
/// `Name.ext`, then `Name 2.ext` up to `Name 99.ext`, and stops at the first name that is free,
/// where `create` makes the copy, or that is a file holding the same contents (`same`, given that
/// file and its metadata), which is kept. When `create` fails with `AlreadyExists` (the name was
/// taken meanwhile), it tries the next name. `None` when no name is free or holds the same
/// contents; a name whose metadata can't be read counts as taken.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn copy_new(
from: &std::path::Path,
dir: &std::path::Path,
create: impl Fn(&std::path::Path) -> std::io::Result<()>,
same: impl Fn(&std::path::Path, &std::fs::Metadata) -> bool,
) -> std::io::Result<Option<Placed>> {
use std::io::ErrorKind;
let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(ErrorKind::InvalidInput.into()) };
let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
for n in 1..=MAX_COPY_NUMBER {
let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) };
match std::fs::symlink_metadata(&to) {
Err(e) if e.kind() == ErrorKind::NotFound => match create(&to) {
Ok(()) => return Ok(Some(Placed::Copied(to))),
// Taken meanwhile: the next name.
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
},
Ok(m) if m.is_file() && same(&to, &m) => return Ok(Some(Placed::Kept(to))),
_ => {}
}
}
Ok(None)
}
/// The most entries `file.list` returns.
const LIST_MAX: usize = 5000;
fn file_list(_: &mut Session, p: &Value) -> Result<Value> {
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad("file.list", "missing `path`"))?;
let (entries, truncated) = list_dir(path, LIST_MAX)?;
Ok(json!({ "path": absolute_path(path), "entries": entries, "truncated": truncated }))
}
/// The entries of folder `path`, sorted by name: at most `max`, and whether there were more. A
/// link isn't followed: its target may lie outside the automation roots.
#[cfg(not(target_arch = "wasm32"))]
fn list_dir(path: &str, max: usize) -> Result<(Vec<Value>, bool)> {
may_read(path)?;
let dir = std::fs::read_dir(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut entries: Vec<(String, Value)> = vec![];
let mut truncated = false;
for e in dir.flatten() {
if entries.len() == max {
truncated = true;
break;
}
let name = e.file_name().to_string_lossy().into_owned();
let Ok(t) = e.file_type() else { continue };
let entry = if t.is_symlink() {
json!({ "name": name, "kind": "link" })
} else if t.is_dir() {
json!({ "name": name, "kind": "folder" })
} else {
json!({ "name": name, "kind": "file", "size": e.metadata().map(|m| m.len()).ok() })
};
entries.push((name, entry));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok((entries.into_iter().map(|(_, v)| v).collect(), truncated))
}
#[cfg(target_arch = "wasm32")]
fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
Err(EngineError::Other("no file system here".into()))
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
#[cfg(not(target_arch = "wasm32"))]
@@ -842,6 +948,11 @@ pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub fn write_file_with(path: &str, _: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn create_dir(path: &str) -> Result<()> {
Err(no_fs(path))
+63 -14
View File
@@ -3,6 +3,7 @@
//! artboard to a separate file, Include Linked Files, Embed ICC Profiles and Create
//! PDF-Compatible File.
use std::borrow::Cow;
use std::collections::BTreeMap;
use serde::Deserialize;
@@ -25,8 +26,8 @@ pub const OPTIONS: &[FormatOption] = &[
FormatOption {
name: "version",
ty: "integer",
default: "3",
description: "the format version to write: 3 (current), or 2 or 1 for older VectorCraft versions (not compressed; newer features they don't know are lost there)",
default: "4",
description: "the format version to write: 4 (current), or 3, 2 or 1 for older VectorCraft versions (1 and 2 are never compressed; newer features they don't know are lost there)",
},
FormatOption {
name: "preview",
@@ -71,11 +72,19 @@ const EMBED_PROFILES: FormatOption = FormatOption {
description: "carry the ICC profiles the document is tagged with that were loaded from files (Edit → Assign Profile), so the document keeps them where they aren't installed",
};
/// The save options of a `.ai` file (a PDF carrying the native document; its PDF options come on
/// top, see document.exportPdf).
/// The save and export options of a `.ai` file (a PDF carrying the native document; its PDF
/// options come on top, see document.exportPdf). Saves always retain every artboard.
pub const AI_OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::USE_ARTBOARDS,
FormatOption {
name: "range",
ty: "string",
default: "null",
description: "1-based artboards such as \"1-3, 5\", or \"all\": the PDF pages to export, or with separateArtboards the files to save separately",
},
SEPARATE_ARTBOARDS,
SEPARATE_RANGE,
INCLUDE_LINKED,
EMBED_PROFILES,
FormatOption {
@@ -126,6 +135,14 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
let mut out = Vec::new();
encode_to(cmd, f, doc, p, &mut out)?;
Ok(out)
}
/// [`encode`], written to `w` as it is made ([`vectorcraft_format::save_to`]: give it a buffered
/// writer).
pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: impl std::io::Write) -> Result<()> {
let (mut so, extras) = save_options(cmd, f, doc, p)?;
// Include Linked Files keeps placed documents' files; older versions get their art.
let full = if so.include_linked || so.version < vectorcraft_format::VERSION {
@@ -133,15 +150,23 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
} else {
std::borrow::Cow::Borrowed(doc)
};
let doc = &*full;
if extras.preview {
so.preview = preview_png(doc)?;
so.preview = preview_png(&full)?;
}
if extras.pdf {
// Every artboard as PDF pages (no editing data: the file is the native document).
so.pdf = Some(super::pdf::encode(cmd, doc, &json!({ "range": "all", "preserveEditing": false }))?.0);
so.pdf = Some(super::pdf::encode(cmd, &full, &json!({ "range": "all", "preserveEditing": false }))?.0);
}
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
let native = match full {
Cow::Owned(full) if so.include_linked && so.version >= vectorcraft_format::VERSION => {
// Include available bytes without replacing missing, relinkable placed objects.
let mut original = doc.clone();
original.images = full.images;
Cow::Owned(original)
}
other => other,
};
vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string()))
}
/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in
@@ -172,16 +197,40 @@ pub(super) fn install_profiles(profiles: BTreeMap<String, Vec<u8>>) -> Vec<Strin
.collect()
}
/// The native document a `.ai` file carries, with the options in `p` that apply to it
/// (`includeLinked`, `embedProfiles`; compact and uncompressed: the PDF compresses it).
pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
/// Check AI options even when chosen PDF pages omit the native editing attachment.
pub(super) fn ai_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<SaveOptions> {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.retain(|k, _| !matches!(k.as_str(), "compress" | "version" | "preview" | "pdfCompatible"));
// AI carries the current native format without a separate preview. Its compression
// and PDF-content flags are still validated as booleans.
o.retain(|k, _| !matches!(k.as_str(), "version" | "preview"));
if o.get("pdfCompatible").is_some_and(Value::is_null) {
o.remove("pdfCompatible");
}
}
let (mut so, _) = save_options(cmd, f, doc, &q)?;
so.pretty = false;
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
so.compress = false; // The PDF compresses its editing attachment.
Ok(so)
}
/// The original native document carried by an AI file, with validated save options.
pub(super) fn ai_native(cmd: &str, doc: &Document, so: &SaveOptions) -> Result<Vec<u8>> {
let native = if so.include_linked {
match crate::cmd::place::document::full_documents(doc).0 {
Cow::Borrowed(d) => Cow::Borrowed(d),
Cow::Owned(full) => {
// Include available files, retaining placed objects even when their files
// are missing. Only drawn output substitutes unlinked preview images.
let mut original = doc.clone();
original.images = full.images;
Cow::Owned(original)
}
}
} else {
Cow::Borrowed(doc)
};
vectorcraft_format::save_with(&native, so).map_err(|e| bad(cmd, e.to_string()))
}
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
+5 -1
View File
@@ -249,7 +249,11 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
/// when the PDF has every artboard (a PDF of some reopens as just those).
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
encode_carrying(cmd, doc, p, || Ok(vectorcraft_format::save(doc, false)))
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?;
warnings.extend(drawn_warnings);
Ok((bytes, warnings))
}
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
+5 -2
View File
@@ -96,7 +96,9 @@ impl LoadOptions {
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers
}
/// The PDF import options: `pages` resolved against the file's page count.
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
/// `.ai` asked for its crop box opens on its trim boxes, its editor's artboards, with the bleed
/// round them (its crop box takes the bleed in).
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
let pages = match &self.pages {
None => None,
@@ -105,9 +107,10 @@ impl LoadOptions {
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
}
};
let is_ai = self.crop == CropTo::Crop && vectorcraft_pdf::has_illustrator_data(bytes, self.password.as_deref());
Ok(ImportOptions {
pages,
crop: self.crop,
crop: if is_ai { CropTo::Trim } else { self.crop },
password: self.password.clone(),
text_as: self.text_as,
layers: self.layers,
+125 -33
View File
@@ -6,6 +6,7 @@
//! the caller's writer (the file system here; a save panel or a browser download in the apps) and
//! makes the file the document's own (path, format, options, saved state) for Save and Save As.
use std::io::Write as _;
use std::sync::Arc;
use serde_json::{Map, Value, json};
@@ -14,7 +15,6 @@ use vectorcraft_doc::Document;
use super::super::*;
use super::{
ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded,
write_file,
};
use crate::{DocState, Prefs};
@@ -300,6 +300,31 @@ pub struct SaveJob {
boards: Vec<usize>,
}
/// Writes the file at a path with what the filler it is given writes into it
/// ([`SaveJob::write_streamed`]).
pub type StreamWrite<'a> = &'a mut dyn FnMut(&str, &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()>;
/// Bytes gathered before a streamed save writes them to the file.
const STREAM_BUFFER: usize = 256 << 10;
/// A writer that counts the bytes written through it.
struct Counted<W> {
inner: W,
n: u64,
}
impl<W: std::io::Write> std::io::Write for Counted<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.n += n as u64;
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
/// The artboards a native or `.ai` save also writes to files of their own: with
/// `separateArtboards`, those `range` names (default all).
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
@@ -353,36 +378,41 @@ fn blank_pages(doc: &Document) -> Document {
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
/// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard
/// with the PDF options, always carrying the native document, its layers and sublayers PDF layers
/// (hidden ones off) unless asked otherwise; Use Compression compresses the PDF's content.
pub(super) fn ai_params(params: &mut Value) {
let Some(o) = params.as_object_mut() else { return };
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
// Apps that read the PDF part (not the native document) then find the layers, the hidden ones
// with their art (#372); a PDF standard decides for itself.
if o.get("standard").is_none_or(Value::is_null) {
o.entry("createLayers").or_insert(json!(true));
/// The shared Save and Export encoder for PDF-compatible `.ai` files.
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
let mut params = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = params.as_object_mut() {
o.insert("preserveEditing".into(), json!(true));
// Keep upstream AI PDF layers, including hidden layers, unless a standard decides.
if o.get("standard").is_none_or(Value::is_null) {
o.entry("createLayers").or_insert(json!(true));
}
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
let compression = o.entry("compression").or_insert_with(|| json!({}));
if let Some(m) = compression.as_object_mut() {
m.insert("compressText".into(), json!(c));
}
}
}
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
let compression = o.entry("compression").or_insert_with(|| json!({}));
if let Some(m) = compression.as_object_mut() {
m.insert("compressText".into(), json!(c));
let native_options = super::native::ai_options(cmd, f, doc, &params)?;
let pdf_compatible = bool_or(&params, "pdfCompatible", true);
if !pdf_compatible {
let picked: ArtboardPick = super::encode::options(f, &params)?;
let picked = picked.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
if picked.is_some_and(|v| !v.into_iter().eq(0..doc.artboards.len())) {
return Err(bad(cmd, "pdfCompatible: false needs every artboard to keep the editable artwork"));
}
}
}
/// Encode `doc` as a PDF-compatible `.ai` file (`params` as [`ai_params`] makes them) for command
/// `cmd`: its pages (blank with `pdfCompatible: false`, which it then notes), carrying the native
/// document. File › Save As writes it, and so do the exports (`document.export`, the CLI).
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, params: &Value) -> Result<Encoded> {
let compatible = bool_or(params, "pdfCompatible", true);
let pages = doc.without_edit_modes();
let pages = if compatible { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
let native = || super::native::ai_native(cmd, f, doc, params);
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, params, native)?;
if !compatible {
warnings.insert(0, NOT_PDF_COMPATIBLE.to_string());
// Resolve placed files only for the drawn pages. Missing files become preview images there;
// the native attachment keeps the original placed objects and their relinkable metadata.
let (pages, placed_warnings) =
if pdf_compatible { crate::cmd::place::document::full_documents(&pages) } else { (std::borrow::Cow::Owned(blank_pages(&pages)), Vec::new()) };
let native = || super::native::ai_native(cmd, doc, &native_options);
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, &params, native)?;
warnings.extend(placed_warnings);
if !pdf_compatible {
warnings.push(NOT_PDF_COMPATIBLE.to_string());
}
Ok(Encoded { warnings, ..Encoded::one(bytes) })
}
@@ -421,8 +451,9 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
}
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
if plan.format.id == "ai" {
ai_params(&mut params);
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
// Save keeps every artboard; exports may choose a subset instead.
o.insert("range".into(), json!("all"));
}
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
}
@@ -437,6 +468,16 @@ impl SaveJob {
&self.snapshot
}
/// The document the job writes (the snapshot, as the file keeps it).
pub(crate) fn doc(&self) -> &Arc<Document> {
&self.doc
}
/// Encode `doc` (the job's document, changed) as the job encodes its own: one file.
pub(crate) fn encode_doc(&self, doc: &Document) -> Result<Encoded> {
self.encode_one(doc)
}
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
/// applied yet…).
@@ -494,6 +535,53 @@ impl SaveJob {
Ok(out)
}
/// Is the file written as it is encoded by [`SaveJob::write_streamed`]? A native file (or
/// template) to a path, without a file per artboard.
pub fn streams(&self) -> bool {
self.plan.path.is_some() && matches!(self.plan.format.id, "vectorcraft" | "template") && self.boards.is_empty()
}
/// [`SaveJob::write`] through `stream`, which writes the file at a path with what the filler
/// it is given writes (atomically, as [`vectorcraft_format::write_atomic_with`]). A native
/// file goes into it as it is encoded ([`SaveJob::streams`]), never held whole in memory;
/// other files are encoded first, then written into it.
pub fn write_streamed(&self, stream: StreamWrite) -> Result<Value> {
let (Some(path), true) = (&self.plan.path, self.streams()) else {
return self.write(|p, b| stream(p, &mut |w| w.write_all(b)));
};
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let doc = self.doc.without_edit_modes();
let doc = match f.id {
// A native file flagged so opening it starts a new untitled document (as
// `encode_all` writes it).
"template" => {
let mut d = doc.into_owned();
d.template = true;
std::borrow::Cow::Owned(d)
}
_ => doc,
};
// The encoder's failure, kept apart from the writer's (which `stream` reports).
let mut failed = None;
let mut written = 0u64;
let mut fill = |w: &mut dyn std::io::Write| {
let mut out = Counted { inner: std::io::BufWriter::with_capacity(STREAM_BUFFER, w), n: 0 };
let r = super::native::encode_to(cmd, f, &doc, &self.params, &mut out);
written = out.n;
match r {
Ok(()) => out.inner.flush(),
Err(e) => {
failed = Some(e);
Err(std::io::Error::other("the file couldn't be encoded"))
}
}
};
let r = stream(path, &mut fill);
r.map_err(|e| failed.take().unwrap_or(e))?;
let warnings: Vec<String> = fidelity_warning(f).into_iter().collect();
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings }))
}
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
/// title, format, options, and the snapshot as the saved state (edits made since the job was
/// made keep the document modified).
@@ -537,10 +625,14 @@ pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8])
Ok(out)
}
/// A save command: plan, then write with the file system.
/// A save command: plan, then write with the file system (a native file as it is encoded).
fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> {
let plan = save_plan(s, mode, p)?;
save_with(s, plan, write_file)
let job = save_job(s, plan)?;
let uid = s.doc()?.uid;
let out = job.write_streamed(&mut super::write_file_with)?;
job.complete(s, uid);
Ok(out)
}
fn can_revert(s: &Session) -> std::result::Result<(), String> {
@@ -557,7 +649,7 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?;
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
doc.template = false;
// Back to the saved file: its recovery copy has nothing left to recover.
let uid = s.doc()?.uid;
@@ -596,7 +688,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+5 -1
View File
@@ -153,10 +153,14 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
let (opts, boards, chosen) = plan(doc, p)?;
// Preserve editing embeds the native document (once, shared by every file).
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
// Draw linked files as vectors when available, or their previews with a warning. Keep
// that preparation out of the native attachment so missing links remain relinkable.
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
let doc = &*full;
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
let doc = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded::default();
let mut enc = Encoded { warnings, ..Encoded::default() };
for artboard in boards {
// A chosen artboard's file holds the art over it (#550).
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
+74 -1
View File
@@ -43,7 +43,7 @@ fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
let doc = &s.doc().unwrap().doc;
match &doc.layers[0].children().unwrap()[0].kind {
NodeKind::Image(im) => im.clone(),
NodeKind::Image(im) => (**im).clone(),
other => panic!("expected an image, got {other:?}"),
}
}
@@ -412,3 +412,76 @@ fn new_files_never_replace_a_file() {
assert_eq!(std::fs::read(&path).unwrap(), b"first");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_native_save_streams_the_bytes_the_encoder_makes() {
let d = tmp_dir("stream");
let mut s = session(200.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] {
let path = d.join(name).to_string_lossy().to_string();
let p = json!({"path": path, "compress": compress});
let r = s.execute("file.saveCopy", &p).unwrap();
let written = std::fs::read(&path).unwrap();
assert_eq!(r["bytes"], written.len(), "{r}");
let plan = save_plan(&s, SaveMode::Copy, &p).unwrap();
let job = save_job(&mut s, plan).unwrap();
assert!(job.streams());
assert_eq!(written, job.encode().unwrap().files[0].1, "{name}");
assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate"));
}
// A writer that fails: the save fails with its error.
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap();
let job = save_job(&mut s, plan).unwrap();
let e = job
.write_streamed(&mut |_, fill| {
fill(&mut std::io::sink()).unwrap();
Err(EngineError::Other("disk full".into()))
})
.unwrap_err();
assert_eq!(e.to_string(), "disk full");
let _ = std::fs::remove_dir_all(&d);
}
/// A native save whose file write fails partway (a full disk) leaves the file as it was, and no
/// temporary file behind.
#[test]
fn a_save_that_fails_while_streaming_leaves_the_file_as_it_was() {
/// Takes `left` bytes, then fails as a full disk does.
struct FailAfter<'a> {
inner: &'a mut dyn std::io::Write,
left: usize,
}
impl std::io::Write for FailAfter<'_> {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
if self.left == 0 {
return Err(std::io::Error::other("disk full"));
}
let n = b.len().min(self.left);
self.left -= n;
self.inner.write(&b[..n])
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
let d = tmp_dir("stream-fail");
let mut s = session(2000.0, 2000.0, 1);
// Enough objects that the file is written in several pieces, the failure in the middle.
for i in 0..1500 {
s.execute("shape.rectangle", &json!({"x": i % 100 * 20, "y": i / 100 * 20, "width": 10, "height": 10})).unwrap();
}
for compress in [false, true] {
let path = d.join("a.vectorcraft").to_string_lossy().to_string();
s.execute("file.saveCopy", &json!({"path": path, "compress": compress})).unwrap();
let before = std::fs::read(&path).unwrap();
s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": path, "compress": compress})).unwrap();
let job = save_job(&mut s, plan).unwrap();
let e = job.write_streamed(&mut |p, fill| super::write_file_with(p, &mut |w| fill(&mut FailAfter { inner: w, left: 10_000 }))).unwrap_err();
assert!(e.to_string().contains("disk full") || e.to_string().contains("couldn't be encoded"), "{e}");
assert_eq!(std::fs::read(&path).unwrap(), before, "compress: {compress}");
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no temporary file is left");
}
let _ = std::fs::remove_dir_all(&d);
}
+88 -3
View File
@@ -1,6 +1,6 @@
//! Illustrator EPS and `.ai` files open from the editing data they carry: layers, groups, names,
//! hidden objects, artboards, colours and images as they were; what the reader doesn't read yet
//! opens as before, with a warning saying why.
//! Illustrator EPS, `.ai` and PDF files open from the editing data they carry: layers, groups,
//! names, hidden objects, artboards, colours and images as they were; what the reader doesn't read
//! yet opens as before, with a warning saying why.
use std::io::Write as _;
use std::sync::Arc;
@@ -8,6 +8,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_testkit::ai;
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use super::*;
@@ -81,6 +82,50 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
#[test]
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
let mut s = Session::new();
let file = ai::pdf(&compressed(&ai::sample_data()), ai::page_pdf());
let r = open(&mut s, "art.pdf", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("pdf"), &json!([])), "{r}");
check_sample(&s);
// Without it: the PDF part.
let r = open(&mut s, "art.pdf", &file, json!({"editingData": false})).unwrap();
let layers = &s.doc().unwrap().doc.layers;
assert_eq!(layers.len(), 1, "{r}");
assert_ne!(names(layers), ["Art"]);
}
/// Art crossing the page's edge opens whole from a PDF's editing data, without the note that the
/// PDF's art was clipped to the page; without the editing data it is clipped, with the note.
#[test]
fn a_pdfs_art_past_the_page_opens_whole_from_its_editing_data() {
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n", ai::rect(150.0, 20.0, 250.0, 80.0))));
let file = ai::pdf(data.as_bytes(), "0 g 150 20 100 60 re f");
let mut s = Session::new();
let r = open(&mut s, "past.pdf", &file, json!({})).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art"]);
assert!(doc.art_bounds().is_some_and(|b| b.x1 > 200.5), "{:?}", doc.art_bounds());
let r = open(&mut s, "past.pdf", &file, json!({"editingData": false})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
assert!(s.doc().unwrap().doc.art_bounds().is_some_and(|b| b.x1 < 200.5));
}
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes:
/// a `.ai`'s art outside its artboards is missing, and a PDF's art past the page is clipped.
#[test]
fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
let damaged = b"%AI12_CompressedData not zlib";
let mut s = Session::new();
let r = open(&mut s, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE]), "{r}");
let r = open(&mut s, "damaged.pdf", &ai::pdf(damaged, "0 g 150 20 100 60 re f"), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
}
#[test]
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
@@ -157,3 +202,43 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
}
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
/// as in the editing data.
fn bled_ai(bleed: f64) -> Vec<u8> {
let art = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(-20.0, -20.0, 60.0, 50.0));
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
let (w, h) = (200.0 + 2.0 * bleed, 100.0 + 2.0 * bleed);
let page = PdfPage {
bleed: Some([0.0, 0.0, w, h]),
trim: Some([bleed, bleed, 200.0 + bleed, 100.0 + bleed]),
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
..PdfPage::new(w, h, &format!("1 0 0 rg {0} {0} 80 70 re f", bleed - 20.0))
};
pdf_with(&[page], &[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())], None)
}
#[test]
fn an_ai_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
let bleed = 10.0 * 72.0 / 25.4;
let file = bled_ai(bleed);
let mut s = Session::new();
// From the editing data, and from the PDF part alone.
for params in [json!({}), json!({"editingData": false})] {
let r = open(&mut s, "bled.ai", &file, params.clone()).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 1, "{params}: {r}");
let ab = doc.artboards[0].rect;
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{params}: {ab:?} {r}");
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{params}: {:?}", doc.setup.bleed);
// The square reaches into the bleed, whole.
let art = doc.art_bounds().unwrap();
let want = [ab.x0 - 20.0, ab.y0 + 50.0, ab.x0 + 60.0, ab.y0 + 120.0];
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{params}: {art:?}");
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{params}: {art:?}");
}
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "editingData: false reads the PDF part");
open(&mut s, "bled.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers are read");
}
@@ -190,3 +190,64 @@ fn a_pdf_of_some_artboards_carries_no_editing_data() {
assert_eq!(warned, !kept, "{range}: {v}");
}
}
#[test]
fn ai_export_of_some_artboards_keeps_only_those_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
let r = s.execute("document.export", &json!({"format": "ai", "range": "2"})).unwrap();
let bytes = b64(&r);
assert!(vectorcraft_pdf::editing(&bytes).is_none());
assert!(r["warnings"].as_array().unwrap().contains(&json!(super::pdf::EDITING_NEEDS_EVERY_ARTBOARD)));
open(&mut s, "one.ai", &bytes);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
let mut s = Session::new();
s.execute("file.new", &json!({"artboards": 3})).unwrap();
assert!(s.execute("document.export", &json!({"format": "ai", "range": "2", "pdfCompatible": false})).is_err());
}
#[test]
fn ai_export_validates_options_even_when_editing_data_is_omitted() {
let mut s = Session::new();
s.execute("file.new", &json!({"artboards": 3})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
for command in ["document.export", "document.serialize"] {
for range in ["all", "2"] {
for option in ["includeLinked", "embedProfiles", "compress", "pdfCompatible"] {
let mut params = json!({"format": "ai", "range": range});
params[option] = json!("no");
assert!(s.execute(command, &params).is_err(), "{command}: {params}");
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
assert!(
s.execute(
command,
&json!({"format": "ai", "range": range, "includeLinked": false, "embedProfiles": false, "compress": false, "pdfCompatible": true})
)
.is_ok()
);
}
}
}
#[test]
fn ai_export_without_artboards_keeps_art_outside_the_original_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 2})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 180, "y": 100, "width": 30, "height": 20})).unwrap();
s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 50, "height": 40})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
let r = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "range": "2"})).unwrap();
let bytes = b64(&r);
let pages = vectorcraft_pdf::import(&bytes).unwrap();
assert_eq!(pages.artboards.len(), 1);
assert_eq!((pages.artboards[0].rect.width(), pages.artboards[0].rect.height()), (30.0, 20.0));
let editing = vectorcraft_pdf::editing(&bytes).unwrap();
let exported = vectorcraft_format::load(&editing.data).unwrap();
assert_eq!(exported.artboards.len(), 1);
assert_eq!(exported.artboards[0].rect, vectorcraft_geom::Rect::new(180.0, 100.0, 210.0, 120.0));
assert_eq!(comparable(&s.doc().unwrap().doc), before);
assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err());
}
@@ -53,7 +53,7 @@ fn images(s: &Session) -> Vec<ImageObject> {
let mut out = vec![];
s.documents()[s.active_index().unwrap()].doc.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
out.push(im.clone());
out.push((**im).clone());
}
});
out
+6 -5
View File
@@ -655,7 +655,7 @@ impl FlattenReport {
art.iter_mut().for_each(opaque);
for path in areas {
let mut n = shape_node(&mut tmp, path.clone(), None);
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0);
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into();
art.push(n);
}
Some(isolated_doc(&tmp, art))
@@ -1054,7 +1054,8 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
n.appearance = Appearance {
items: vec![AppearanceItem::Fill(FillLayer { overprint: fill.overprint, ..FillLayer::new(fill.paint) })],
..Appearance::default()
};
}
.into();
n.opacity = fill.alpha.min(1.0);
children.push(Arc::new(n));
vector += 1;
@@ -1092,7 +1093,7 @@ fn raster_node(d: &mut Document, r: Raster) -> Node {
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
let image = Node::new(
d.alloc_id(),
NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() }),
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),
);
let Some(path) = r.clip else { return image };
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
@@ -1223,7 +1224,7 @@ fn carry(from: &Node, mut to: Node) -> Node {
to.blend = from.blend;
to.isolate = from.isolate;
to.knockout_shape = from.knockout_shape;
to.mask = from.mask.clone();
to.extras_mut().mask = from.extras().mask.clone();
to.appearance.effects = from.appearance.effects.clone();
to
}
@@ -1257,7 +1258,7 @@ fn has_effects(n: &Node) -> bool {
}
fn masked(n: &Node) -> bool {
n.mask.as_ref().is_some_and(|m| !m.disabled)
n.extras().mask.as_ref().is_some_and(|m| !m.disabled)
}
/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing).
+17 -34
View File
@@ -45,9 +45,6 @@ const MAX_ADD_FILES: usize = 1_000;
pub(crate) const MAX_FONT_FILE: u64 = 256 << 20;
#[cfg(not(target_arch = "wasm32"))]
const MAX_ADD_BYTES: u64 = 1 << 30;
/// The highest number a copy's name gets (`Name 99.otf`).
#[cfg(not(target_arch = "wasm32"))]
const MAX_NUMBER: u32 = 99;
/// The extensions of the font files `text.addFontFiles` copies (in any case), besides suitcase
/// fonts.
#[cfg(not(target_arch = "wasm32"))]
@@ -88,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Font Files",
[],
None,
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: a file with the same contents is kept, and when another file has that name, the copy gets a number (Name 2.otf); then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
always,
add
),
@@ -367,18 +364,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// Where [`copy_one`] put a file: copied to, or kept as, the file in the folder.
#[cfg(not(target_arch = "wasm32"))]
enum Placed {
Copied(PathBuf),
Kept(PathBuf),
}
/// Copy `files` into `dir`, never replacing a file there: a file with the same contents is kept,
/// and when another file has the name, the copy gets the first free name from `Name 2.otf` to
/// `Name 99.otf`. At most [`MAX_FONT_FILE`] bytes a file and 1 GB in all.
/// Copy `files` into `dir`, never replacing a file there ([`super::fileio::copy_new`]): the names
/// `Name.otf`, `Name 2.otf` up to `Name 99.otf` are tried in turn until one is free, which gets
/// the copy, or is a file with the same contents, which is kept. At most [`MAX_FONT_FILE`] bytes a
/// file and 1 GB in all.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn copy_into(files: &[PathBuf], dir: &Path) -> CopyReport {
use super::fileio::Placed;
let mut report = CopyReport::default();
let mut total = 0;
for from in files {
@@ -398,10 +390,10 @@ pub(crate) fn copy_into(files: &[PathBuf], _: &Path) -> CopyReport {
}
#[cfg(not(target_arch = "wasm32"))]
fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<Placed, String> {
use std::io::ErrorKind;
fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<super::fileio::Placed, String> {
use super::fileio::Placed;
let meta = std::fs::symlink_metadata(from).map_err(|e| e.to_string())?;
if !meta.is_file() {
if !meta.is_file() || from.file_name().is_none() {
return Err(NOT_A_FILE.into());
}
let font = from.extension().and_then(|e| e.to_str()).is_some_and(|e| FONT_EXTENSIONS.iter().any(|x| e.eq_ignore_ascii_case(x)));
@@ -418,25 +410,16 @@ fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<Pla
if total.saturating_add(len) > MAX_ADD_BYTES {
return Err(TOO_MUCH.into());
}
let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(NOT_A_FILE.into()) };
let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
for n in 1..=MAX_NUMBER {
let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) };
match std::fs::symlink_metadata(&to) {
Err(e) if e.kind() == ErrorKind::NotFound => match create_copy(from, &to, fork.as_deref()) {
Ok(()) => {
*total += len;
return Ok(Placed::Copied(to));
}
// Taken meanwhile: the next name.
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.to_string()),
},
Ok(m) if m.is_file() && same_file(from, &to, m.len(), len, fork.as_deref()) => return Ok(Placed::Kept(to)),
_ => {}
let create = |to: &Path| create_copy(from, to, fork.as_deref());
match super::fileio::copy_new(from, dir, create, |to, m| same_file(from, to, m.len(), len, fork.as_deref())) {
Ok(Some(Placed::Copied(to))) => {
*total += len;
Ok(Placed::Copied(to))
}
Ok(Some(kept)) => Ok(kept),
Ok(None) => Err(NO_NAME.into()),
Err(e) => Err(e.to_string()),
}
Err(NO_NAME.into())
}
/// Create `to` as a copy of the font file `from`, or for a suitcase font, as an empty file whose
File diff suppressed because it is too large. Load diff
+28 -22
View File
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Collect in New Layer",
["Window", "Layers"],
None,
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}",
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer), deselecting every selected object left hidden or locked (the key object too) → {id}",
has_doc,
collect
),
@@ -107,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Rows",
[],
None,
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}",
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. Every selected object left hidden or locked, such as one moved into a hidden layer or group, is deselected (the key object too). One undo step → {ids}",
has_doc,
move_rows
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder",
[],
None,
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)",
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers), deselecting every selected object left hidden or locked (the key object too)",
has_doc,
move_node
),
@@ -189,15 +189,16 @@ pub fn specs() -> Vec<CommandSpec> {
/// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current
/// layer (capped at [`MAX_ROWS`]).
pub(crate) fn rows_param(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let mut ids = match (ids_param(p, "ids"), id_param(p, "id")) {
(Some(ids), _) => ids,
(None, Some(id)) => vec![id],
(None, None) => {
let st = s.doc()?;
let rows = st.highlighted_rows();
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
}
pub(crate) fn rows_param(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
// Presence is explicit intent: validate every target before fallback, truncation or edits.
let mut ids = if p.get("ids").is_some() {
checked_ids_param(s, p, "ids", command)?
} else if let Some(id) = p.get("id") {
vec![checked_id(s, id, command)?]
} else {
let st = s.doc()?;
let rows = st.highlighted_rows();
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
};
ids.truncate(MAX_ROWS);
Ok(ids)
@@ -457,7 +458,7 @@ fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
let ids = rows_param(s, p)?;
let ids = rows_param(s, p, "layer.delete")?;
let st = s.doc()?;
let ids = row_roots(&st.doc, &ids);
if ids.is_empty() {
@@ -484,7 +485,7 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
}
fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
let ids = rows_param(s, p)?;
let ids = rows_param(s, p, "layer.duplicate")?;
let ids = row_roots(&s.doc()?.doc, &ids);
if ids.is_empty() {
return Err(bad("layer.duplicate", "no such rows"));
@@ -551,7 +552,7 @@ fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.setProps";
check_options(C, p)?;
let ids = rows_param(s, p)?;
let ids = rows_param(s, p, C)?;
if ids.is_empty() {
return Err(bad(C, "no rows"));
}
@@ -567,12 +568,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
fn collect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.collectInNew";
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => {
let rows = s.doc()?.highlighted_rows();
if rows.is_empty() { super::edit::selected_roots(s)? } else { rows }
}
let ids = if p.get("ids").is_some() {
checked_ids_param(s, p, "ids", C)?
} else {
let rows = s.doc()?.highlighted_rows();
if rows.is_empty() { super::edit::selected_roots(s)? } else { rows }
};
let ids = row_roots(&s.doc()?.doc, &ids);
let Some(top) = ids.last().copied() else { return Err(bad(C, "select rows or objects to collect")) };
@@ -589,6 +589,7 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
d.move_node(*id, Some(lid), usize::MAX)?;
}
sel.prune(d);
sel.deselect_uneditable(d);
Ok(lid)
})?;
let st = s.doc_mut()?;
@@ -713,6 +714,7 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
index += 1;
}
sel.prune(d);
sel.deselect_uneditable(d);
Ok(out)
})?;
let st = s.doc_mut()?;
@@ -730,7 +732,11 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
if !accepts(d, parent, n) {
return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups"));
}
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
s.edit("Reorder", |d, sel| {
d.move_node(id, parent, index)?;
sel.deselect_uneditable(d);
Ok(())
})?;
ok()
}
+18 -13
View File
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge Selected",
["Window", "Layers"],
None,
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}",
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. Every selected object left hidden or locked, such as one merged into a hidden or locked layer, is deselected (the key object too). One undo step → {id}",
has_doc,
merge
),
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Artwork",
["Window", "Layers"],
None,
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}",
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. Every selected object left hidden or locked, such as one flattened into a locked layer, is deselected (the key object too). One undo step → {id, discarded}",
has_doc,
flatten
),
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer), which show. One undo step → {count}",
has_doc,
|s, p| others(s, p, Flag::Visible, false)
|s, p| others(s, p, Flag::Visible, false, "layer.hideOthers")
),
cmd!("layer.showAll", "Show All Layers", ["Window", "Layers"], None, "{} show every layer and sublayer → {count}", has_doc, |s, _| all(
s,
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer), which preview → {count}",
has_doc,
|s, p| others(s, p, Flag::Preview, false)
|s, p| others(s, p, Flag::Preview, false, "layer.outlineOthers")
),
cmd!(
"layer.previewAll",
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer), which unlock → {count}",
has_doc,
|s, p| others(s, p, Flag::Locked, true)
|s, p| others(s, p, Flag::Locked, true, "layer.lockOthers")
),
cmd!(
"layer.unlockAll",
@@ -196,16 +196,16 @@ fn set_flag(s: &mut Session, changes: Vec<(NodeId, bool)>, flag: Flag, label: &s
}
/// The top-level layers holding the rows a command acts on.
fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
let ids = rows_param(s, p)?;
fn kept_layers(s: &Session, p: &Value, command: &str) -> Result<HashSet<NodeId>> {
let ids = rows_param(s, p, command)?;
let d = &s.doc()?.doc;
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
}
/// Every top-level layer but the kept ones set to `v`, and the kept ones to the opposite: Alt-click
/// on a hidden layer's eye shows just that layer (#1158).
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
let keep = kept_layers(s, p)?;
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool, command: &str) -> Result<Value> {
let keep = kept_layers(s, p, command)?;
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
set_flag(s, changes, flag, flag.label(v))
}
@@ -221,7 +221,7 @@ fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
}
fn template(s: &mut Session, p: &Value) -> Result<Value> {
let ids = rows_param(s, p)?;
let ids = rows_param(s, p, "layer.template")?;
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(Node::is_layer)).collect();
let Some(first) = ids.first().and_then(|id| d.node(*id)) else { return Err(bad("layer.template", "select a layer")) };
@@ -268,7 +268,7 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.merge";
let ids = rows_param(s, p)?;
let ids = rows_param(s, p, C)?;
let st = s.doc()?;
let d = &st.doc;
let mut seen = HashSet::new();
@@ -288,6 +288,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Merge Selected", |d, sel| {
merge_into(d, into, &others)?;
sel.prune(d);
sel.deselect_uneditable(d);
Ok(())
})?;
let st = s.doc_mut()?;
@@ -300,7 +301,10 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.flatten";
let st = s.doc()?;
let d = &st.doc;
let from = id_param(p, "id").or_else(|| st.highlighted_rows().last().copied()).or(st.current_layer());
let from = match p.get("id") {
Some(id) => Some(checked_id(s, id, C)?),
None => st.highlighted_rows().last().copied().or(st.current_layer()),
};
let into = from.and_then(|id| d.layer_of(id)).ok_or_else(|| bad(C, "no layer"))?;
if !d.node(into).is_some_and(|n| n.visible) {
return Err(EngineError::Other("the layer to flatten into is hidden".into()));
@@ -315,6 +319,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
d.drop_guides_of_deleted_layers(sel);
merge_into(d, into, &others)?;
sel.prune(d);
sel.deselect_uneditable(d);
Ok(())
})?;
let st = s.doc_mut()?;
@@ -389,7 +394,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.reverse";
let ids = rows_param(s, p)?;
let ids = rows_param(s, p, C)?;
let d = &s.doc()?.doc;
let ids = row_roots(d, &ids);
// Rows grouped by the layer or group holding them (None: top-level layers).
Loaded 100 of 414 files, more files were not shown because too many files have changed in this diff. Show more