Compare commits

...
Author SHA1 Message Date
bflatastic 2879c206a8 Fix #2541: installed plug-ins stay installed after a restart
Install Plug-in (plugin.install) now keeps the plug-in's module in a
Plug-ins folder next to the preferences, as Illustrator keeps plug-ins in
its Plug-ins folder, and the desktop app and vectorcraft-cli install the
ones there at startup (a module that doesn't load is skipped with a log
record, at most 512 MB are read). Remove Plug-in deletes it from the
folder. The Additional Plug-ins Folder preference is still loaded from
where it is. Confined MCP servers and VECTORCRAFT_NO_PREFS keep nothing,
as with the PDF preset and colour profile stores.

Docs: docs/mcp.md, docs/plugins.md, docs/target-app-parity.md.
2026-10-17 17:49:38 +07:00
BFlat e78116e0a2 Merge pull request #2609 from dotCooCoo/fix/history-labels-translated
i18n: the History panel translates the undo step names the sources write as literals
2026-10-17 17:41:28 +07:00
bflatastic 351612c978 i18n: Dutch, Danish and Indonesian undo-step names for the History panel (#2609) 2026-10-17 17:26:59 +07:00
dotCooCoo 7930a0456b i18n: the History panel translates the undo step names the sources write as literals
The History panel looked up each step's name with tl!, which reads only
a plain catalog row. 95 of the 427 names that the engine, the tools and
the shell give their undo steps had no such row in at least one complete
catalog, and 83 had no row at all (Make Blend, New Guide, Pen, Reshape
Spine and others). A step named after an effect has the effect's menu
name without its ellipsis. For many effects, and for steps such as New
Layer, the catalogs hold that name only with the ellipsis. The panel
listed those steps in English.

Each complete catalog now has a row for every step name, written in the
catalog's own terms: 1,437 rows in the 19 catalogs. The History panel
reads a step's row, or else the row of the same name with an ellipsis,
without the ellipsis, and shows the name as recorded when the catalog
has neither. A new test reads the step names from the sources, as the
tl! and message tests read theirs, and requires a row for each in every
complete catalog.

Docs: docs/target-app-parity.md.
2026-10-17 17:22:15 +07:00
BFlat fdac986bb4 Merge pull request #2686 from storytold/fix/2441-group-select-compound-part
Part of #2441: Group Selection picks and moves one path of a compound path; i18n and docs for the sixth batch
2026-10-17 17:18:26 +07:00
BFlat fcdddadeb8 Merge pull request #2625 from dotCooCoo/fix/puppet-pins-keep-save-clean
Background save: dropped Puppet Warp pins, ended Edit Text and font scans leave the document saved
2026-10-17 17:17:51 +07:00
BFlat 97cd86d461 Merge pull request #2626 from dotCooCoo/feat/artboards-panel-shift-range
Artboards: Shift-click in the Artboards panel selects a range of artboards
2026-10-17 17:17:36 +07:00
BFlat 47b1fa52cb Merge pull request #2628 from dotCooCoo/feat/export-overflow-warning
Export: PDF, EPS, SVG, EMF, WMF and image exports warn about type that doesn't fit its frame
2026-10-17 17:17:22 +07:00
BFlat 21dc3bf565 Merge pull request #2629 from dotCooCoo/fix/exit-isolation-keeps-lock-guides
Guides: Lock Guides keeps guides deselected after Undo, Exit Isolation Mode and Paste
2026-10-17 17:17:08 +07:00
BFlat 6ecf568c5d Merge pull request #2630 from dotCooCoo/fix/issue-2345
Part of #2345: a marquee does not select a clip group by its hidden art
2026-10-17 17:16:50 +07:00
BFlat 7d677eaa13 Merge pull request #2631 from dotCooCoo/feat/lock-variables
Variables: Lock Variables stops variables from being defined, deleted or renamed
2026-10-17 17:16:35 +07:00
BFlat 20a40f5b1b Merge pull request #2632 from MarekSpodBiedry/issue-2260
Layers panel: drop a layer under the last row to move it to the bottom (#2260)
2026-10-17 17:16:18 +07:00
BFlat e3a67119c1 Merge pull request #2633 from dotCooCoo/fix/save-panels-offer-their-file-type
Save: save panels for files other than documents offer the file type they write
2026-10-17 17:16:01 +07:00
BFlat 003ec8f373 Merge pull request #2635 from dotCooCoo/fix/issue-2460
Part of #2460: a huge selection of type, images, symbols or meshes shows its bounds
2026-10-17 17:15:52 +07:00
BFlat 1fd7ab495a Merge pull request #2665 from dotCooCoo/fix/print-tiling-artboard-edge
Print: a Print Tiling origin on an artboard's right or bottom edge is measured from that artboard
2026-10-17 17:15:35 +07:00
BFlat e7e149a8bd Merge pull request #2637 from dotCooCoo/fix/art-on-artboard-edge
Artboards: art centered on an artboard's right or bottom edge is on that artboard
2026-10-17 17:15:19 +07:00
bflatastic 4417c4feb7 Docs: the ROADMAP's 2026-10-17 row includes the sixth batch 2026-10-17 17:13:04 +07:00
dotCooCoo b0e78cf3d8 Background save: dropped Puppet Warp pins, ended Edit Text and font scans leave the document saved
Selecting other art drops Puppet Warp pins, ending Edit Text clears the
flat view, and a font scan lays type out again. None of these makes an
undo step or counts as an edit to save, but each one replaced the
document. A background save that finished afterwards compared the
document with the one it wrote by pointer, so the document stayed
marked modified. Data Recovery also wrote a new copy of a document whose
pins had been dropped.

DocState now records the documents that these changes replaced, and
DocState::same_content counts them as having the same content. The
modified flag, the end of a background save and Data Recovery use it.
After Edit Text ends, an undo state that was the saved one stays the
saved one.
2026-10-17 17:13:03 +07:00
dotCooCoo 1401051603 Artboards: Shift-click in the Artboards panel selects a range of artboards
With the Artboard tool in use, a Shift-click on a row of the Artboards
panel selects the artboards from the last row clicked without Shift to
the clicked row, and the clicked row's artboard becomes the active one.
When the artboard of the last row clicked without Shift is no longer
selected, the range starts from the active artboard, and later
Shift-clicks start from there too. Ctrl/Cmd-click still adds or removes
one artboard, and a plain click selects one. The public Select
artboards page lists Shift-click in the Artboards panel for selecting
several artboards.

A Shift-click used to add or remove one artboard, as Ctrl/Cmd-click
does (#1139).

Docs: docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
dotCooCoo 14a3740e51 Export: PDF, EPS, SVG, EMF, WMF and image exports warn about type that doesn't fit its frame
Print and Print Preview name the area type and type on a path that
prints without the text past the end of its frame (#2308). The exports
that draw type gave no such warning: a PDF, EPS, SVG, EMF, WMF or
raster export of a frame too small for its text answered with no
warning about it.

These exports now add the same warning, with "isn't drawn in the
export" in place of "doesn't print". Each export counts the visible
objects that it draws. Those are the objects on the pages or artboards
it writes, or all of them in an EPS of the visible art and in an SVG
without chosen artboards. A PDF leaves out layers that don't print
unless includeNonPrinting or createLayers is set. The objects are found
with the walk that fonts::printed_type runs for Print, and typecmd.rs
builds the message with the code that builds Print's. A .ai file keeps
the whole text in its editing data and gives no warning.

Docs: docs/mcp.md (Fitting area type), docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
dotCooCoo 30c5bd3589 Guides: Lock Guides keeps guides deselected after Undo, Exit Isolation Mode and Paste
With View > Guides > Lock Guides on, Session::select drops the paths
made into guides from the selection (#1534), but commands that set the
selection another way skipped that filter and left a locked guide
selected, so the next move moved it. object.exitIsolation selected an
isolated guide path. Undo, Redo, a canceled undo group, a canceled
drag or dialog preview and a failed command.batch brought back locked
guide paths and ruler guides in the selection. Unlock All, Show All
and Paste selected the guide paths they unlocked, showed or pasted.
Locking the guides deselected them in the active document only.

The filter is now deselect_guides, which also clears the selected
ruler guides. Session::select and Session::edit run it while Lock
Guides is on, and so do object.exitIsolation, Undo, Redo,
Session::cancel_interaction, a canceled undo group and a failed batch.
Locking the guides deselects them in every open document. With Lock
Guides off, the selection is set as before.

Docs: docs/mcp.md.
2026-10-17 17:13:03 +07:00
dotCooCoo e09038041f Part of #2345: a marquee does not select a clip group by its hidden art
A marquee measured a clip group, and the objects on a layer with a
clipping mask, by all of their art, including the art the mask hides.
A marquee around a rectangle drawn next to a clip group also selected
the group when it crossed the group's hidden art. A click already
counts only the art inside the clipping path.

The marquee now takes a clip group, or an object on a clipping layer
or sublayer, only when it meets the area inside the clipping path, and
it measures the art there within that area's bounds. The same rule
applies to the objects that Direct and Group Selection select whole,
to Group Selection's E marquee, and in isolation mode when the clip
group or clipping layer itself, or one object directly inside it, is
isolated. The anchor points that Direct Selection's marquee picks are
unchanged.

Docs: docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
dotCooCoo 59f63e9161 Variables: Lock Variables stops variables from being defined, deleted or renamed
Nothing locked the variables: any command or panel button could delete
or rename a variable that scripts and data sets name.

variable.lock {locked?} (Lock Variables: a lock button in the Variables
panel's bottom bar and an item in its menu) sets a flag that is saved
with the document. Without `locked` it toggles, and a change is one
undo step. While the flag is set, variable.define, variable.delete,
variable.rename and the four variable.make…Dynamic commands are refused
with "variables are locked", and the panel disables New Variable…,
Variable Options…, Delete Variable and Make … Dynamic. Binding,
unbinding and the dataset commands still run. variable.list
reports `locked`. A file without the flag opens unlocked.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
MarekSpodBiedry 336d860ed3 Layers panel: drop a layer under the last row to move it to the bottom (#2260) 2026-10-17 17:13:03 +07:00
dotCooCoo 7bc9fd7220 Save: save panels for files other than documents offer the file type they write
Document Info's Save, Save Swatch Library and Save Graphic Style
Library to a file, the Libraries panel's Export Library…, the presets
dialogs' Export…, the CSS Properties export, Print to a PostScript
file and Keyboard Shortcuts › Export… passed no file type to the save
panel. On Windows the panel listed "All Files", and neither the Windows
nor the macOS panel added the extension to a name typed without one.

Each of these saves now passes the one file type it writes, as Save and
Export do for document formats. The Windows panel then lists it, and
the Windows and macOS panels add its extension. The labels are the ones
the open dialogs use for these files (Swatch libraries, Libraries, the
presets, Shortcut sets), the Window menu's Graphic Style Libraries, Text
for Document Info, CSS and PostScript.
2026-10-17 17:13:03 +07:00
dotCooCoo cec7d23d96 Part of #2460: a huge selection of type, images, symbols or meshes shows its bounds
A selection with more than 100,000 anchors shows its bounds instead of
every outline and anchor (#525). The count took in path anchors only.
Type, images, symbol instances, placed documents, gradient meshes and
envelopes counted nothing, although the selection overlay outlines each
of them: a box, an area type frame or a type path, a baseline marker
for point type, and the points and mesh lines of meshes and envelopes.
The PDF importer makes type objects from a page's text lines, one image
object per image and one gradient mesh per patch or triangle of a mesh
shading, so selecting all of a large PDF outlined each of them: 131,072
selected mesh pieces need 16.8 million vertices, a 336 MB vertex
buffer, past wgpu's 256 MiB buffer limit.

The count now takes in what the overlay draws for each kind. Paths
count their anchors. Area type and type on a path count the anchors of
their frame or path, and point type counts six: its box's corners and
the ends of its baseline marker. Images, symbol instances, placed
documents, and area type or type on a path in perspective count the
four corners of their box, and gradient meshes and envelopes count
their points and the ends of their mesh lines. The selection's outline,
the key object's outline and the hover highlight all use the count.

Docs: docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
bflatastic c18069e6ed Print Tiling: the origin's artboard is found with the document's own edge rule (#2637) 2026-10-17 17:13:03 +07:00
bflatastic fb8578ddf0 i18n: rows for the strings this batch adds (Danish, Dutch, Indonesian labels; gradient-mesh image messages in every complete language) 2026-10-17 17:13:03 +07:00
bflatastic 546b8846b6 Part of #2441: Group Selection picks and moves one path of a compound path
A click with the Group Selection tool on a compound path selects the path
under the pointer (its outline within the pick tolerance, else the innermost
path whose area holds the click), so the outer shape and each hole can be
selected on their own; Shift-click adds another, and a click on a selected
path adds the compound path, as a click on a group member climbs to its group.

A path selected this way is an object of its own for Move, Transform, Rotate
and the other transforms, as it already was inside an isolated compound path:
the hole of a ring moves without the ring. Paint and the other commands still
go to the compound path, which holds the paint.

Docs: docs/target-app-parity.md, docs/ui-parity.md.

# Conflicts:
#	docs/target-app-parity.md
2026-10-17 17:13:03 +07:00
dotCooCoo 24b6486ebd Artboards: the doc comment names the shared corner the first artboard holds
On an edge two artboards share, the artboard to the right of it or below
it holds a point, except at the end of the edge that lies on that
artboard's bottom or right edge, such as where both artboards' bottom
edges meet. There the first artboard holds it, or the last when
topmost. The doc comment and the test comments now say so, and the test
asserts that corner.
2026-10-17 17:13:02 +07:00
dotCooCoo d22ccebc9c Print: a Print Tiling origin on an artboard's right or bottom edge is measured from that artboard
The Print Tiling tool's press and print.tiling.set without `artboard`
decided which artboard a point is on with Rect::contains, which leaves
out the right and bottom edges. A press exactly on an artboard's right
or bottom edge picked no artboard when no other artboard holds that
point, so the drag did not snap to that artboard's edges. An origin
there was measured from the first printed artboard: with artboards at
0 to 300 pt and 300 to 600 pt, an origin on the second one's right edge
was saved as x 600 instead of 300.

Both now count the edges as Document::art_on_artboard does, within a
millionth of a point.
2026-10-17 17:13:02 +07:00
BFlat fa4e42762b Merge pull request #2614 from dotCooCoo/fix/artboard-art-symbol-bounds
Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art
2026-10-17 17:12:16 +07:00
BFlat 2045fe3de1 Merge pull request #2638 from dotCooCoo/fix/spine-whole-number-index
Blends, meshes and envelopes: an index or count that is not a whole number is refused
2026-10-17 17:12:05 +07:00
BFlat eb672e8f42 Merge pull request #2641 from dotCooCoo/fix/data-sets-rewrite-hidden-type
Variables: a data set rewrites bound type on hidden layers
2026-10-17 17:11:53 +07:00
BFlat 82e2c9506b Merge pull request #2644 from dotCooCoo/feat/trackpad-rotate-view
Rotate View: a two-finger rotation on a Mac trackpad turns the view about the pointer
2026-10-17 17:11:41 +07:00
BFlat fff1acac06 Merge pull request #2645 from dotCooCoo/fix/huge-selection-type-path-brackets
Type: a huge selection of type on a path draws no per-object brackets
2026-10-17 17:11:25 +07:00
BFlat 8186f9e2e4 Merge pull request #2647 from dotCooCoo/fix/too-large-note-counts-rgba
Images: the too-large note counts the image's pixels as RGBA
2026-10-17 17:11:14 +07:00
BFlat 5f0d809047 Merge pull request #2648 from dotCooCoo/fix/rasterize-warns-undecodable-image
Rasterize, Flatten Transparency: commands that replace art with a rendering warn about an undecodable image
2026-10-17 17:10:58 +07:00
BFlat f19a71367d Merge pull request #2650 from dotCooCoo/fix/issue-2422
Part of #2422: a selected clip group shows its clipping path's outline, not its hidden art
2026-10-17 17:10:48 +07:00
BFlat 49686460aa Merge pull request #2656 from dotCooCoo/fix/saved-bleed-and-spacing-in-range
New Document and Document Setup: values out of range in a saved preset or a file are read in range
2026-10-17 17:10:33 +07:00
BFlat 8ecb22851d Merge pull request #2657 from Silvertree2010/fix/h2438-css-svg-export-writes-an-invalid
Escape quotes and backslashes in exported CSS font-family
2026-10-17 17:10:20 +07:00
BFlat 2aa7412e3a Merge pull request #2659 from dotCooCoo/fix/object-mosaic-whole-counts
Object Mosaic: columns and rows that are not whole numbers are refused
2026-10-17 17:10:01 +07:00
BFlat 18d01ae6ff Merge pull request #2664 from dotCooCoo/fix/issue-2276
Fix #2276: the Eyedropper and RGB input give color-managed CMYK values in a process document
2026-10-17 17:09:45 +07:00
BFlat 3c5034b3dd Merge pull request #2667 from dotCooCoo/fix/ai-import-stale-outline-note
.ai import: type from the layers opens without the PDF part's outlines warnings
2026-10-17 17:09:30 +07:00
BFlat 1168b2373f Merge pull request #2538 from vangel76/fix/1676-mesh-from-image
Gradient mesh: Create Gradient Mesh on an embedded image (#1676)
2026-10-17 17:09:15 +07:00
BFlat 03e867df02 Merge pull request #2512 from vangel76/feat/2342-symbol-nine-slice
Symbols: Symbol Options › Enable Guides for 9-Slice Scaling (#2342)
2026-10-17 17:08:56 +07:00
BFlat 0c0267acbf Merge pull request #2416 from VL420/feat/global-rulers
G7: View › Rulers › Change to Global / Artboard Rulers
2026-10-17 17:08:45 +07:00
BFlat 3e9c590ee4 Merge pull request #2582 from dotCooCoo/fix/appearance-inline-choice-params
Appearance: the inline effect editor shows choice parameters as dropdowns with the dialog's labels
2026-10-17 17:08:32 +07:00
BFlat 06f0700155 Merge pull request #2594 from dotCooCoo/fix/rotated-view-cursors
Rotate View: resize and rotate cursors turn with the view
2026-10-17 17:08:15 +07:00
BFlat 3981e0bd03 Merge pull request #2453 from VL420/feat/artistic-filters-1
G2: Effect › Artistic › Cutout, Neon Glow and Poster Edges
2026-10-17 17:08:03 +07:00
BFlat 37f578ff34 Merge pull request #2472 from Janiz-zzz/fi-review-20261016
i18n(fi): review the machine-drafted Finnish rows
2026-10-17 17:07:53 +07:00
dotCooCoo 8f87ad53dd Artboards: art centered on an artboard's right or bottom edge is on that artboard
Rearrange All Artboards, Select › All on Active Artboard (for objects
with no area), Copy's source artboard for Paste in Place, Align to
Artboard without an index, Crop Image, the Crop Image tool, Place with
`at`, CSS positions and a filter plug-in's artboard decided whether a
point or an object's center is on an artboard with Rect::contains,
which leaves out the right and bottom edges. A line along an artboard's right or
bottom edge was on no artboard: Rearrange left it behind, and All on
Active Artboard did not select it. Document::art_on_artboard counts
art flush with those edges (#890).

They now count the edges as art_on_artboard does, within a millionth
of a point. On an edge two artboards share, the artboard to the right
of it or below it still holds the point. All on Active Artboard
selects a line on a shared edge from either artboard. A press exactly
on a right or bottom edge now picks that artboard, and the Artboard
tool's cursor shows Move there.
2026-10-17 16:38:43 +07:00
dotCooCoo 67b47c7262 Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art
Rearrange and Select › All on Active Artboard measured a symbol
instance as its transform applied to the 20 × 20 square around the
symbol's origin, not by the symbol's art. An instance whose art lies
away from that square moved with, or was selected on, the artboard
the square was on. Convert to Artboards, Transform Each, Lock and
Hide › All Artwork Above, Paste and Place from Library at a point,
and the clipboard imports measured the instance the same way, as did
Copy and Cut when they recorded the source artboard and library.add
when it sized a graphic.

These commands now measure an instance by its art through
Document::symbol_boxes, as Document::art_on_artboard, Fit to Artwork
Bounds and the Align commands do. Clipboard::bounds measures with the
symbols the clipboard holds. clipboard.exportPng, clipboard.exportPdf
and the PNG and PDF that Copy offers other apps are now cropped to
the instance's art.

Docs: docs/mcp.md.
2026-10-17 16:38:43 +07:00
dotCooCoo 1a9509b776 Blends, meshes and envelopes: an index or count that is not a whole number is refused
The blend spine commands and the gradient mesh point commands read
their anchor, segment or index as a number and dropped its fraction,
so object.blend.spine.moveAnchor with anchor 1.5 moved point 1 and
reported success. object.envelope.setMeshPoint did the same and also
read a negative index as 0. Specified Steps in object.blend.make and
object.blend.options, and the rows and columns of object.mesh.create,
object.envelope.makeWithMesh and resetWithMesh, dropped their fraction
in the same way.

These commands now fail with "`<key>` must be a whole number" for a
fraction, and object.envelope.setMeshPoint fails with "index must be
≥ 0" for a negative index, as the other point commands do. Whole
numbers written as 4.0, as the Blend Options and Make with Mesh
dialogs send them, still work.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:43 +07:00
dotCooCoo 5669e02331 Variables: a data set rewrites bound type on hidden layers
dataset.select skipped every bound object inside a hidden layer or
group. Type on a hidden layer kept the previous row's text, which
showed when the layer was shown again, and dataset.list and the
Variables panel reported every row as matching that type while the
layer stayed hidden.

dataset.select, dataset.next and dataset.prev now rewrite type and
apply the other values on hidden objects and inside hidden layers and
groups. Those objects stay hidden unless a visibility value shows
them. Objects that are locked or inside a locked layer or group are
still skipped and counted in `skipped`. dataset.list, dataset.capture
and dataset.update read hidden objects as well. Binding still refuses
art inside a hidden layer or group.

The values now go in the order the variables are defined. The passes
that applied visibility values before and after the other values are
removed.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 16:38:43 +07:00
dotCooCoo 4285ccdda3 Rotate View: a two-finger rotation on a Mac trackpad turns the view about the pointer
A two-finger rotation on a Mac trackpad did not turn the view. With
the new General preference Trackpad Gesture to Rotate View
(`trackpadRotateView`, on by default) and the pointer over the canvas,
the rotation turns the view about the pointer, keeping the document
point under it in place. The view does not turn until the rotation
reaches 5°; it then turns by the whole rotation and follows each later
rotation event. A rotation ends 0.25 s after its last event. A rotation
in the same frame as a pinch zooms and turns about the same point.

egui reports the gesture as `Event::Rotate`: egui-winit makes it from
winit's macOS rotation gesture, and eframe makes it from Safari's
gesture events in the web build. Rotation events that arrive while
fingers are on a touch screen do not turn the view. With the
preference off, the gesture does not turn the view.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/hardware-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo c5405479b9 Type: a huge selection of type on a path draws no per-object brackets
The Selection and Direct Selection tools drew the start, center and end
brackets of every selected type on a path, and hit-tested each of
them, however large the selection was: 32,768 selected type on a path
objects added 1,310,720 vertices to the frame's mesh, 40 for each
object. The PDF importer makes type on a path for curved text lines,
so selecting all of a large PDF can reach that size.

With more than 10,000 type on a path objects selected, the tools now
neither draw nor hit-test any bracket: a press where a bracket would
be does not start a bracket drag, and the pointer does not change to
the bracket cursor there. The tools draw 10 points for each object's
brackets, so 10,000 objects take 100,000, the same number as the
canvas's limit on the anchor points it outlines. The tools crate has
its own constant with the same value.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo df16d21e84 Images: the too-large note counts the image's pixels as RGBA
File › Open, File › Place and the Links panel note an image that needs
more than the decode allowance (2 GiB, 512 MiB in the web build). The
note counted the bytes the decoder gives, one a pixel for an 8-bit or
1-bit gray image, so a 1-bit gray PNG of 23,171 × 23,170 pixels (537 MB
as gray, 2,147,488,280 bytes as RGBA) got no note, although sampling,
recoloring and Load Texture refuse it for its RGBA size.

The note now counts width × height × 4, or the decoder's bytes when
they are more, against the allowance, with the size computed in 64
bits. Such an image gets the note an oversized RGBA image gets. Images
within the allowance get no note, as before.
2026-10-17 16:38:42 +07:00
dotCooCoo 133fd73feb Rasterize, Flatten Transparency: commands that replace art with a rendering warn about an undecodable image
Object > Rasterize, Crop Image, Flatten Transparency, Expand
Appearance of raster effects and Vector Halftone draw the art with the
renderer and replace it with the result. When the art held an image
that could not be decoded, the renderer drew nothing for it, and the
result lacked the image with no message. A raster export of the same
art warns that "an image couldn't be decoded and is missing from the
picture".

These five commands now compare decode_failures before and after they
draw, as raster exports do, and their replies carry that text as
`warning`. They still make their result in one undo step. The app
shows a command's `warning` in the status bar: after a menu command,
after OK in a dialog with a preview, and after a tool runs a command
(Crop Image's Apply).

Docs: docs/mcp.md (Flatten Transparency, Expand Appearance, Document
Raster Effects Settings, Vector halftone).
2026-10-17 16:38:42 +07:00
dotCooCoo 8ee5f96da5 Part of #2422: a selected clip group shows its clipping path's outline, not its hidden art
A selected clip group drew the outline of everything inside it: the
clipping path, the whole rectangle of a clipped image, and the whole
outline and anchors of each clipped path. The Selection tool's
bounding box, which fits the clipping path, showed on top of them.
The hover highlight and the key object outline drew the same hidden
art.

The selection overlay, the hover highlight and the key object outline
now show only the clipping path of a clip group or clipping layer,
its first child. The overlay's limit on anchors counts only that path
too, so a clip group that hides a large trace still shows its
clipping path's anchors and hover highlight. The art inside still
shows its whole outline when it is selected on its own: with Edit
Contents, a Direct or Group Selection click, or in isolation mode.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo e74b09c86d New Document and Document Setup: values out of range in a saved preset or a file are read in range
file.new takes a width and height up to 163,830 pt, 1 to 1,000
artboards, a spacing of 0 to 100,000 pt and a bleed of 0 to 72 pt a
side, but a saved or Recent New Document preset in the preferences
was read as it was stored. With a width of -5, file.new failed with
"width/height must be positive and within the canvas"; a spacing of
-50 overlapped the artboards, a preset with 5,000 artboards made 5,000
artboards, and a bleed of 100 reached the document. A .vectorcraft
file's Document Setup bleed, superscript and subscript proportions and
small caps size also opened as stored, and document.setup then failed
when its own report was passed back to it.

These values are now read as the nearer end of the range their
setter takes, and an artboard width or height of 0 or less is read as
1 pt. Presets and files with values in range read as before.

Docs: docs/mcp.md (Document Setup, New Document).
2026-10-17 16:38:42 +07:00
NetterSilverandClaude Haiku 5.5 a631a0df6c Escape quotes and backslashes in exported CSS font-family
Found while testing crates/svg/src/css.rs:84 (font_props)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 16:38:42 +07:00
dotCooCoo d18135bfcd Object Mosaic: columns and rows that are not whole numbers are refused
object.createObjectMosaic read columns and rows with as_u64 and fell
back to 10 for anything else, so 2.5, -3 or "4" made a 10 x 10 mosaic
and reported success. Transform Each read reference and seed the same
way (a reference of 2.5 used the center point, and a seed of 7.5 used
the default seed), and Rearrange All Artboards cut 1.5 columns to 1
and read "1" as the default 2.

These five parameters now take a whole number, written 4 or 4.0 as the
Rearrange All Artboards dialog sends it. A fraction or a negative
number is refused with "`columns` must be a whole number", the message
object.slice.divide gives. A string or another value that is not a
number is refused with the check_numbers error. The command then
changes nothing. The existing ranges stay: 1 to 1000 mosaic tiles
across and down, a reference of at most 8, and 1 to the artboard
count of columns.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo 3adf5141e7 Fix #2276: the Eyedropper and RGB input give color-managed CMYK values in a process document
In a CMYK document, an RGB color from the Eyedropper, a hex value or
paint.setFill was stored as CMYK by the profile-free formula
(k = 1 - max(r, g, b)), not through the color settings. RGB 83/170/223
came in as C63 M24 Y0 K13. A CMYK image was sampled through the RGB its
decoder shows, so its inks came back only when the formula happened to
round-trip them.

paint_from now converts an RGB color into a CMYK document with
Cms::convert and the settings' intent, as Document Color Mode and Swatch
Options already do. This covers paint.setFill and paint.setStroke,
gradient stops, swatch.new {color} and the Eyedropper. With Generic CMYK,
83/170/223 now separates to C59 M12 Y3 K0, and #ff0000 now separates to
C0 M97 Y100 K0 instead of C0 M100 Y100 K0. keepModel: true still keeps
RGB. paint.sampleColor with a stop stores the color the same way.

The Eyedropper samples a CMYK image's ink amounts (a four-component
JPEG, or an 8- or 16-bit chunky CMYK TIFF) and returns them unchanged
(ImageBlob::sample_cmyk_object), also in an RGB document. It takes an
RGB image's sRGB pixel into the working RGB space, as the screen color
sampler does, so the picked color shows as the pixel when the working
space is not sRGB.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo 1651dadf41 .ai import: type from the layers opens without the PDF part's outlines warnings
A PDF-compatible .ai read through its editing data kept the PDF part's
warnings that text was kept as outlines ("text was converted to
outlines", the U+FFFD warning and the warning on glyphs that differ
from the installed font). The layers take from the page only text
nodes and the stroked paths around them, so filled outlined glyphs
don't reach the document, and type made from the text document holds
every character.

- The warnings are dropped when the document holds no glyphs kept as
  outlines. Stroked glyphs that come in with stroked type keep them.
- A text object that takes the page's type, next to glyphs the page has
  only as outlines, lacks those characters: a new warning counts such
  objects.
- vectorcraft_pdf exports the three warnings, is_outlines_note,
  is_glyph_outlines and holds_outlines.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:41 +07:00
vangel76andClaude Opus 5.5 e4ad256d7d Gradient mesh: Object > Create Gradient Mesh on an embedded image (#1676)
The mesh covers the image's rectangle with its transform kept; each point takes the average colour and opacity of the picture around it. A linked image is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:38:41 +07:00
vangel76andClaude Opus 5.5 c692962a22 Symbols: fuzz nineSlice params of symbol.new and symbol.options (#2342)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:38:41 +07:00
VictorandClaude Opus 5.5 276eeacae6 G7: View › Rulers › Change to Global / Artboard Rulers
View › Rulers › Change to Global Rulers (Cmd+Alt+R) was a disabled
stub: the rulers always counted from one document zero point. The
reference app's public help describes two modes: artboard rulers, the
default, count from the active artboard's top-left corner, and each
artboard can have its own zero point; global rulers keep one zero point
for the whole document whichever artboard is active.
https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html
https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html

`view.globalRulers {global?}` switches the mode (toggle by default),
undoable and saved with the document (`rulers`). The menu item reads
Change to Artboard Rulers while global rulers are on. A new document has
artboard rulers; a file saved before the mode existed has no `rulers`
key and opens with global rulers, so it measures as it did.

With artboard rulers, dragging the ruler corner sets the active
artboard's zero point, kept as an offset from its corner
(`artboard_ruler_origins`, by artboard id) so it moves with the
artboard, and double-clicking the corner resets it. The global zero
point (`ruler_origin`) stays separate. `document.rulerOrigin`,
`document.setRulerOrigin` and `document.resetRulerOrigin` take
`artboard`; `Document::ruler_origin_for` is the zero point with an
artboard active, falling back to the corner or the global one when a
stored value is out of range.

The rulers, the Transform, Properties and Control bar X/Y, the Info
panel's X/Y (which ignored the zero point before), ruler-guide
readouts and Shift ruler-tick snapping all count from that zero point:
the view's active artboard reaches the tools through `ViewInfo` and
`ToolContext::ruler_origin`. Command coordinates stay absolute.

Tests: engine/tests/ruler_origin.rs (default mode, per-artboard origin
following its artboard, global origin apart, toggle and undo, save and
legacy files, out-of-range values), ui-egui/src/tests_ruler_origin.rs
(position fields and corner drag on the active artboard, menu label and
shortcut), tools/src/rulerguide.rs (guide readouts and tick snapping).
Tests written for the single global zero point now turn global rulers
on first. "Change to Artboard Rulers" is translated in every catalog.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01U2JbUtM4MkzWLm9FgYiEsq
2026-10-17 16:38:41 +07:00
vangel76andClaude Opus 5.5 3eed742639 Symbols: Symbol Options › Enable Guides for 9-Slice Scaling (#2342)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:38:41 +07:00
bflatastic 5b1e18f2fc Appearance: the inline editor's checkboxes take their label by reference, as check_box now names them (#2611) 2026-10-17 16:38:40 +07:00
dotCooCoo baec72c0fe Appearance: the inline effect editor shows choice parameters as dropdowns with the dialog's labels
The Appearance panel's inline effect editor showed a parameter that
picks one of some values (Roughen's Points, Offset Path's Joins, the
menus of Smart Blur, Glass, Texturizer and De-Interlace) as a text
field holding the stored value, where the effect's dialog has a
dropdown. Its labels did not have the dialog's mappings ("Channel1"
and "Max Radius" for "Channel 1:" and "Max. Radius:"), and a plug-in
choice named mode whose value is a blend mode name showed the blend
mode list.

The editor now shows the other parameters that effect::choices lists
as dropdowns with the dialog's labels (Drop Shadow's Shade keeps its
radio buttons), translated in the "effect" context, and a pick sends
the dialog's value with effect.setParams. Glass's Texture menu ends
with Loaded Texture once a texture is loaded. A plug-in's choice lists
the plug-in's values, and the blend mode dropdown is only for our own
effects. The labels are form::humanize's without the colon. The dialog
and the panel both call form::choice_label and effect::glass_textures.

Docs: docs/target-app-parity.md.
2026-10-17 16:18:32 +07:00
dotCooCoo a12af589aa Rotate View: resize and rotate cursors turn with the view
In a rotated view the resize and rotate cursors were the ones for an
unturned view. With the view turned 90°, the right-middle handle of a
selection's bounding box shows at the middle of the box's bottom edge
and drags up and down, but its cursor was the left-right arrow. This
affected the Selection, Free Transform, Artboard, Slice Selection, Crop
Image, Perspective Grid and Perspective Selection tools, ruler guides,
and the spread handle of a freeform gradient.

Now each of these cursors turns with the view to the nearest 45°. The
Selection tool and the Perspective Selection tool turn the handle's
direction by the view's angle before they round it to a cursor.
ToolContext::view_angle reads the view's angle from the screen frame,
and Cursor::turned turns a resize or rotate cursor by it. The new
Cursor::rotate_about_in_view turns the pointer's direction from the
center by the view before it picks one of the rotate cursor's eight
directions. Cursor::rotate_about keeps its signature and behavior. At
0° every cursor is as it was.

Docs: docs/target-app-parity.md.
2026-10-17 16:18:28 +07:00
VictorandClaude Opus 5.5 d1a6e39b7e G2: Effect › Artistic › Cutout, Neon Glow and Poster Edges
Three original Artistic filters on the pixel pipeline, each with a
catalogue row, the generic live dialog, translations and tests.

- Cutout: colours softened by Edge Simplicity (less with Edge Fidelity),
  split into Number of Levels steps per channel and painted as flat
  pieces in their average colour; slivers along softened edges take the
  nearest large piece's colour.
- Poster Edges: channels posterized to Posterization + 2 levels, black
  lines on the edges of any channel and of the outline, widened by Edge
  Thickness and darkened by Edge Intensity.
- Neon Glow: dimmed greys screened with a glow colour from the light
  areas (positive Glow Size) or the dark ones (negative), as strong as
  Glow Brightness.

All keep coverage and work in document units at any Document Raster
Effects resolution. Photoshop filters 33/57; cargo xtask ci passes all
9 steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:54 +07:00
Janiz-zzzandClaude Opus 5.5 838d81157e i18n(fi): review the machine-drafted Finnish rows
Native review of the rows added in the Finnish merge: "artboard" is
piirtoalusta everywhere else in the catalog, so the two export rows that
said piirtoalue now match; "Color Scheme" becomes Väriteema so it no
longer reads the same as Swatches (Värimallit). The other added rows
read correctly and are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:53 +07:00
BFlat 009384b2e7 Merge pull request #2666 from storytold/pr/batch-1017b-i18n-docs
i18n and docs: rows and ROADMAP entry for the fifth 2026-10-17 batch
2026-10-17 15:55:00 +07:00
BFlat 8776f6c896 Merge pull request #2577 from dennismutuku2005/fix/print-settings-windows-2569
Print: open printing preferences for the selected printer on Windows …
2026-10-17 15:54:20 +07:00
BFlat 150467c7f9 Merge pull request #2542 from danngreen/feat/move-remembers-last
Move dialog opens remembers the last drag or move values
2026-10-17 15:54:06 +07:00
BFlat 08afdd101d Merge pull request #2406 from dotCooCoo/feat/graph-column-designs
Graphs: Object › Graph › Column draws columns with graph designs
2026-10-17 15:53:52 +07:00
BFlat 93cdb4c4e3 Merge pull request #2424 from dotCooCoo/feat/live-paint-gap-options
Live Paint: Gap Options find and close gaps in Live Paint groups
2026-10-17 15:53:37 +07:00
BFlat c01eeff7ed Merge pull request #2437 from dotCooCoo/feat/svg-interactivity
Window: the SVG Interactivity panel attaches JavaScript events and files for SVG export
2026-10-17 15:53:20 +07:00
bflatastic 9ca534ba2e i18n: the SVG Interactivity labels take the Danish, Dutch and Indonesian wording from #2437 2026-10-17 15:50:49 +07:00
BFlat a329e499ce Merge pull request #2496 from dotCooCoo/feat/new-artboard-options
Artboards: Alt-click on New Artboard opens Artboard Options for the new artboard
2026-10-17 15:49:21 +07:00
BFlat 2217ad5009 Merge pull request #2556 from dotCooCoo/feat/contextual-task-bar-artboard
Contextual Task Bar: the Artboard tool offers Resize, Duplicate, Rearrange All and Export
2026-10-17 15:48:57 +07:00
BFlat 9745847617 Merge pull request #2559 from dotCooCoo/feat/control-bar-artboard-options
Control bar: the Artboard tool shows the active artboard's preset, name, X, Y, W and H
2026-10-17 15:48:38 +07:00
BFlat 403bc4a804 Merge pull request #2563 from dotCooCoo/feat/optical-margin-alignment
Type: Optical Margin Alignment hangs punctuation and letter edges outside the frame
2026-10-17 15:48:24 +07:00
BFlat 7b28595c11 Merge pull request #2616 from dotCooCoo/feat/expand-pattern-fills
Expand: pattern fills expand into their tiles clipped by the shape
2026-10-17 15:48:09 +07:00
BFlat 0e18cf2a08 Merge pull request #2619 from dotCooCoo/fix/save-added-extension-asks-before-replacing
Save and Export ask before replacing a file that an added extension names
2026-10-17 15:47:52 +07:00
bflatastic fe559785c5 i18n: the replace-file question takes the Danish, Dutch and Indonesian wording from #2619 2026-10-17 15:47:26 +07:00
BFlat e91140e743 Merge pull request #2536 from japbcoelho/fix/document-profiles
PDF: colours are written in the profiles assigned to the document
2026-10-17 15:46:54 +07:00
BFlat bbd015651d Merge pull request #2469 from dotCooCoo/feat/touch-type-tool
Type: the Touch Type tool moves, scales and rotates single characters
2026-10-17 15:46:41 +07:00
BFlat b11c9a9c57 Merge pull request #2524 from dotCooCoo/feat/place-rtf
Place: RTF files place as area type with their formatting
2026-10-17 15:46:26 +07:00
BFlat 2f01a1df92 Merge pull request #2562 from dotCooCoo/feat/brushes-multiselect
Brushes: Shift-click and Ctrl/Cmd-click select several brushes, and Delete Brush deletes them
2026-10-17 15:46:12 +07:00
BFlat 0332936393 Merge pull request #2573 from dotCooCoo/feat/blend-panel
Blend: Window › Blend opens a Blend panel with the blend's spacing, orientation and commands
2026-10-17 15:46:01 +07:00
BFlat 06ad0becf1 Merge pull request #2624 from dotCooCoo/fix/blend-spine-messages-translated
i18n: blend spine errors are translated in every complete language
2026-10-17 15:45:46 +07:00
bflatastic b2586232ae Docs: the ROADMAP's 2026-10-17 row includes the fifth batch 2026-10-17 15:22:14 +07:00
bflatastic 72a6efc814 Docs: drop the parity rows that Optical Margin Alignment, Hyphenation and the Blend panel replaced 2026-10-17 15:22:00 +07:00
bflatastic 53b75a0acb i18n: Danish, Dutch and Indonesian rows for the strings this batch adds 2026-10-17 15:19:25 +07:00
bflatastic 74ee51313d Print: Printer Setup passes only an installed printer's name to PrintUIEntry (#2569) 2026-10-17 15:14:43 +07:00
Dan Green 6daf74fb6d Move dialog opens on the last drag or Move, with its last checkboxes 2026-10-17 15:14:43 +07:00
dotCooCoo b9309f7985 Graphs: Object › Graph › Column draws columns with graph designs
Object › Graph › Column… was a menu stub, so a graph's columns and bars
were always plain rectangles. The reference app fills them with a graph
design, vertically scaled, uniformly scaled, repeated with a value for
each copy, or stretched at a guide line.

graph.column (Column…) draws the columns, bars or stacked segments and
the legend swatch of the series picked with Group Selection with a
design saved by Object › Graph › Design. Vertically Scaled stretches the
design to each column's length at the column's width. Uniformly Scaled
scales it both ways to that length and centers it on the column.
Repeating stacks a copy for each Each Design Represents value out from
the zero line, and For Fractions chops the last copy at the value or
scales it down. Sliding keeps the part above the design's horizontal
guide line in proportion and stretches the part below. The design and
its options are saved with the graph, and the graph follows the design
when it is renamed or deleted. Columns drawn with a design cast no drop
shadow.

A marker or legend design made of one path is now drawn in a group of
its own. Painting a series with Group Selection then leaves the design's
paint alone, as it already did for designs of several objects.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 15:14:43 +07:00
dotCooCoo f9759afa30 Live Paint: Gap Options find and close gaps in Live Paint groups
Object > Live Paint > Gap Options was a stub, and paint leaked through
any gap between the paths of a Live Paint group. The reference app's
Gap Options stop paint at small gaps, close them with unpainted paths
and preview them on the canvas.

livePaint.gapOptions (Gap Options) sets a group's options: detect (Gap
Detection), size (small 3 pt, medium 6 pt, large 12 pt, or a custom
size over 0 up to 72 pt), color (the Gap Preview Color, #rrggbb or a
layer color's name) and closeGaps, which adds an unpainted path across
each gap to the group's paths. A gap runs from a loose end of a path
to the nearest point of any path within the size, unless the paths
already join the two close by. With detection on, the faces are made
again as if a line closed each gap, and each face or edge keeps the
look of the old one it lies in or on. The options are stored on the
group, so they are saved, copied and undone with it, and Expand drops
them. Merge keeps the bottom group's options when its Gap Detection is
on, or else turns Gap Detection on with Small when another merged
group has it on. When Gap Detection is off in every merged group,
Merge keeps it off. livePaint.info lists the options and the gaps up
to the size wide that no path closes.

The menu item opens a Gap Options dialog. With Preview on, the options
apply live and the gaps show on the canvas in the preview color, and
OK keeps them as one undo step. vectorcraft-pathops gains find_gaps
and live_paint_with_gaps.

Docs: docs/mcp.md, docs/target-app-parity.md.
i18n: the 19 complete catalogs have rows for the dialog's labels, and
bg's Gap Options menu label now names gaps instead of line spacing.
2026-10-17 15:14:43 +07:00
dotCooCoo be553fa3cf Window: the SVG Interactivity panel attaches JavaScript events and files for SVG export
Window › SVG Interactivity was a disabled menu item. VectorCraft could
not attach JavaScript to an object's events or link JavaScript files
for SVG export.

The SVG Interactivity panel lists 19 events, from onfocusin to
onscroll. Pick one, type its JavaScript and press Enter to add it to
the selected objects; the list shows their events. Remove Selected
Entry removes the picked event, and the panel menu's Clear Events
removes all of them. Link JavaScript Files… opens the JavaScript
Files dialog, which adds a path or URL to the document's list and
removes the selected one. Its Choose… button puts a picked file in
as a file:/// URL. The commands are svgInteractivity.info,
setEvent, removeEvent, clearEvents, addFile and removeFile, and
ui.javascriptFilesDialog. Each edit is one undo step. A document
links at most 64 files.

SVG export writes each event as an event attribute on the object's
element and each file as a <script xlink:href> element. With the SVG
Tiny 1.2 profile, export leaves the events out with a warning. SVG
import reads the listed event attributes and the script links back
as text. It never reads a linked file or runs a script, and it leaves
inline scripts and the links after the first 64 out with a warning.
The native format keeps both.

An unnamed <g> with data-* attributes of its own around a single
object now imports as a group. Before, import folded it into the
object, which then took the group's data-* or, when it had its own,
dropped the group's. An unnamed <g> with event attributes stays a
group in the same way. The SVG import fuzz tests now include event
attributes and <script> elements.

Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md,
docs/ui-parity.md.
2026-10-17 15:14:43 +07:00
DENNIS MUUO b959cd90da Print: open printing preferences for the selected printer on Windows (#2569)
When pressing Ctrl+P to open the Print dialog and clicking Setup (or Settings), print.printerSetup previously discarded the selected printer name and unconditionally launched explorer ms-settings:printers. On Windows 10, this opened the Windows Settings Printers and scanners page, displaying the list of installed printers instead of the printer driver printing preferences.

Launch Windows native Printing Preferences dialog for the target printer using rundll32.exe printui.dll,PrintUIEntry /e /n printer, falling back to the system default printer or Settings if no printer is available or detected. Add unit tests covering command construction and fallback behavior.

Fixes #2569.
2026-10-17 15:14:43 +07:00
dotCooCoo 8bcebf0e48 Artboards: Alt-click on New Artboard opens Artboard Options for the new artboard
An Alt-click (Option-click on macOS) on the Artboards panel's New
Artboard used to add an artboard at once, as a plain click does. It now
opens Artboard Options for the new artboard, filled in with the name and
size that artboard.new {} would give it, and adds nothing yet. OK adds
the artboard with the fields where artboard.new {} would place it (20 pt
to the right of the last artboard) in one undo step, and makes it the
active artboard; Cancel adds no artboard. A plain click and a row
dropped on the button work as before.

The new UI command ui.newArtboard ("New Artboard Options…") opens the
dialog. It is listed after ui.newLayer, and its label has a row in each
catalog.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 15:14:42 +07:00
dotCooCoo a6bac3298b Contextual Task Bar: the Artboard tool offers Resize, Duplicate, Rearrange All and Export
The Contextual Task Bar showed only with the selection tools and
selected objects. With the Artboard tool it never showed.

With the Artboard tool, the bar now sits under the selected artboards
and offers Resize, which opens Artboard Options for the active
artboard; Duplicate, which copies each selected artboard in one undo
step and selects the copies; Rearrange All, with two or more
artboards, which opens Rearrange All Artboards; and Export, which
opens Export for Screens with the selected artboards checked. The
objects' Fill, Recolor Artwork and Lock buttons are not shown with
the Artboard tool. "Resize" has a row in every catalog.

Docs: docs/target-app-parity.md.
2026-10-17 15:14:42 +07:00
dotCooCoo 0b6f3285ab Control bar: the Artboard tool shows the active artboard's preset, name, X, Y, W and H
With the Artboard tool in use, the Control bar showed Move and Scale
Artwork with Artboard and then the object controls (Fill, Stroke,
Opacity and the rest). The reference app's help lists the Control
panel, with the Artboard tool, among the places to add, delete,
rename, resize and position artboards, set their orientation and
reference point, and open Artboard Options.

While the tool is in use, the Control bar now shows the active
artboard's controls in place of the object controls: Preset, Portrait
and Landscape, New Artboard, Delete Artboard, the Name field, Artboard
Options, Rearrange All Artboards, the Transform link, Move and Scale
Artwork with Artboard, and the reference point with X, Y, W and H and
the Constrain Width and Height Proportions link. Each control runs the
command its Properties or Transform panel counterpart runs, and the
reference point is the one the Transform panel uses. In an 800-point
window the controls up to the Transform link fit, the two artwork
options show as their boxes with their names in tooltips, and the link
opens the position and size in a popover, as it does for objects.

Docs: docs/target-app-parity.md.
2026-10-17 15:14:42 +07:00
dotCooCoo d949c3d8b7 Type: Optical Margin Alignment hangs punctuation and letter edges outside the frame
Type › Optical Margin Alignment was a disabled menu item. The reference
app's Optical Margin Alignment hangs Roman punctuation and the edges of
letters such as W and A outside the margins of every paragraph of a
type object.

type.opticalMarginAlignment (Type › Optical Margin Alignment) turns it
on or off for the selected type in one undo step, and the menu item is
checked while the type has it on. In every left-to-right paragraph of
point and area type, quotation marks, hyphens, full stops and commas at
a line's start or end hang wholly outside the margin there. Asterisks,
tildes, ellipses, en and em dashes, colons and semicolons hang by half
their width, and A, V, W, Y, v, w and y hang by a tenth of theirs. A
punctuation mark followed by a quotation mark hangs with it.
Left-aligned lines hang at their left margin, right-aligned lines hang
at their right margin, and centered and justified lines hang at both.
Lines break where they did before, and the space that justification
adds between glyphs leaves out the gaps next to a hanging punctuation
mark.

The setting is off for new type and for documents from before it, and
the type object saves it only when it is on.

The Type menu's checks for Optical Margin Alignment, Type Orientation
and the Type on a Path effects now read the type inside a selected
group, as the commands do.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
2026-10-17 15:14:42 +07:00
dotCooCoo 62fd793603 Expand: pattern fills expand into their tiles clipped by the shape
Object > Expand with Fill expanded gradient fills only. A pattern fill
stayed a live pattern, and object.expand.info did not report it, so the
dialog's Fill option was disabled for it. In the reference app, Expand
with Fill turns a pattern fill into the pattern's tiles, and the shape
becomes their clipping mask.

The Fill step now turns each visible pattern fill of a path or compound
path into a clip group shaped like the object, holding the tiles the
pattern lays over the object's box with fresh ids. The group isolates
the tiles' blending and takes the fill's opacity, blend mode and other
effects. Geometry effects of the fill or the object, such as Offset
Path, shape the group's outline and the box the tiles cover. A path in
the tile art with a random Transform effect is baked with its own id
first, and a path with a brush that varies at random (calligraphic or
scatter with Random variation, or bristle) is baked where the pattern
defines it first, so every tile matches the canvas. The object's other
fills and strokes stay on copies of it in paint order, and Expand stays
one undo step. Type with a pattern fill expands the same way once
Object outlines it, and object.expand.info reports pattern fills under
fill. It also reports gradient fills under a new gradient key, and the
dialog enables Expand Gradient To only when the selection has one. A
pattern fill stays as it is when it lays no tiles over the box, when a
group or type in its art has a random Transform effect, when the
pattern is scaled, rotated, reflected, stretched or sheared and its art
holds a symbol instance, and when the pattern is rotated, reflected,
stretched or sheared, or scaled while its art has geometry effects, and
its art has raster or 3D effects, or a geometry effect on a group or
type.

PatternDef gains instances_in_with, which places the tiles with a given
Scaling. Expand has stroke weights, effect distances and the tiles' own
pattern paints scale with the pattern's transform. When the pattern is
rotated, reflected, stretched or sheared, or scaled while its art has
geometry effects such as Roughen, the strokes and geometry effects of
the tiles' paths become filled art first (bake_appearance, and Outline
Stroke's conversion for brushed strokes), and their live shapes become
plain paths, in their opacity masks too. Tiles place their opacity
masks with them, unlinked ones included (place_art), which also fixes
unlinked masks in pattern tiles in PDF, EPS, EMF and WMF export and in
Distort Pattern Fills.

Faults in the shared code are fixed for every caller. assemble
dropped the effects of a sole part, so Expand lost a lone gradient
fill's own effects and Outline Stroke lost those of a stroke with a
gradient along or across it. Outline Stroke also dropped a brushed
stroke's raster effects from its brush art. Expand replaced an object
whose gradient fill could not be expanded with a plain copy; it now
leaves it as it was. Expand left the geometry effects of a gradient
fill, and with Object off those of the object, on the expanded art,
where they reshaped every strip; they now shape the clip group's
outline, and the gradient fits it, as for pattern fills.
bake_appearance dropped a stroke's raster effects from its filled
outline and filled the outline of a stroke with a gradient along or
across it with a gradient across the outline's box; it now keeps the
effects and makes the gradient meshes clipped to the outline, as
Outline Stroke does. Envelope Options > Distort Appearance uses it too.
PatternDef::offsets_covering overflowed on a region too large to count
in integers, as for a pattern scaled by 1e-20; it now gives no offsets.

Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
2026-10-17 15:14:42 +07:00
dotCooCoo f526671534 Save and Export ask before replacing a file that an added extension names
After the desktop save panel closes, the app adds the file type's
extension to a name typed without it (or with one the save does not
write). The panel asked only about the name as typed, so on Linux
typing `card` with `card.vectorcraft` in the folder wrote over
`card.vectorcraft` without a question. On Linux the save panel is the
XDG desktop portal, and its GTK and GNOME file choosers return the
name as typed. Windows and macOS add the extension themselves only
when the panel lists file types, and the panels of Document Info's
Save, the presets dialogs' Export…, the CSS Properties export, the
swatch and graphic style library saves and the Libraries panel's
export list none.

When the added extension names a file that exists, Save, Save As, Save
a Copy, Save as Template, the exports and the other writes through a
save panel now ask "“card.vectorcraft” already exists. Replace it?"
first (dialog `replaceFile`). OK runs the write again with the same
answer and replaces the file. Cancel writes nothing and shows the
dialog it was asked over again. The host reports whether a file exists
through a new `Services::exists`.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-17 15:14:42 +07:00
japbcoelhoandClaude Opus 5.5 92a801a04f PDF: colours are written in the profiles assigned to the document
A document assigned a CMYK profile (Edit > Assign Profile) keeps it when
it is saved, but the working spaces are the app's, and a new session
starts with the defaults. Opened again, such a document exported its CMYK
colours tagged with the working CMYK space (VectorCraft Generic CMYK
(SWOP-like)) instead of its own profile, made that profile the PDF/X
output intent, and separated its RGB colours through it; print
separations did the same. edit.assignProfile still reported the assigned
profile, and document.exportPdf's documentation says CMYK colours are
tagged with "the document's CMYK profile".

Found on a book cover assigned FOGRA39L Coated: built in several
vectorcraft-cli runs, each reopening the saved document, its PDF/X-4
came out tagged with the SWOP-like profile unless every run set FOGRA39
as the working CMYK space again.

Export and print now work in the document's colour spaces: the active
settings with the assigned profiles in their place (document_cms). An
assigned profile that isn't available leaves the working space, with a
warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:14:42 +07:00
dotCooCoo 53100a458f Type: the Touch Type tool moves, scales and rotates single characters
The toolbar listed the Touch Type tool (Shift+T), but choosing it did
nothing. The reference app's Touch Type tool moves, scales and rotates
one character of live type by dragging the handles of a box around it.

A click with the tool now selects one character and shows a box around
it. Dragging the box or its lower-left handle moves the character along
its line and up or down. The upper-left handle scales it vertically,
the lower-right handle scales it horizontally, the upper-right handle
scales it proportionally, and the upper-middle handle rotates it. A
character rotated by up to about 84 degrees follows the pointer too.
Near a quarter turn, a drag moves it only along its line. Escape
cancels a drag, and otherwise lets go of the character.

Each drag is one text.touchType command and one undo step. It sets the
character's baseline shift, scales and rotation, and moves it along its
line by changing its own kerning and the kerning of the character
before it, within the kerning range. The characters after it stay
where they are. Where the font kerned the character with its
neighbors, the two kerning values also keep the character in place
when it is only raised, scaled or rotated. The characters after it
stay where they were, or move by the change in its width when it is
scaled. The first character of a line and a right-to-left character
move only up and down. The type stays editable.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
2026-10-17 15:14:42 +07:00
dotCooCoo 0ed83870a4 Place: RTF files place as area type with their formatting
File > Place read text files only as plain `.txt`, and an RTF file
could not be placed. The reference app lists RTF among the formats it
places as text, keeping the file's font and style settings.

File > Place now reads RTF files, written from the Rich Text Format
Specification 1.9.1, and sets their text as area type the way a text
file is placed. Each run keeps its font from the font table, its size,
bold and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), baseline
shift and its color from the color table. Each paragraph keeps its
alignment, left, right and first-line indents, space before and after,
and direction. Table cells are separated by tabs and each table row is
a paragraph. Unicode characters, Windows-1252 and Mac Roman bytes and
the special-character control words are read, and of a \upr group's
ANSI and Unicode text, the Unicode text is read. Hidden text, pictures,
objects, headers, footers, notes, field instructions and other
destinations are left out, and unknown control words are skipped.

A file is read as RTF when it starts with {\rtf, whatever its name,
except a .txt file, which stays plain text. RTF files share the 4 MiB
limit of text files. Groups nested more than 512 deep share the
formatting of the deepest one read, the font and color tables keep at
most 4,096 entries, and a file with more than 100,000 paragraphs or
runs is refused. The Place dialog lists RTF files, and file.place,
file.place.info and file.place.queue report the format `rtf`.

The tests in tests_textimport.rs fail without the change, and
text_import_fuzz.rs places random RTF token streams.

Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
2026-10-17 15:14:41 +07:00
dotCooCoo e7b78e7150 Brushes: Shift-click and Ctrl/Cmd-click select several brushes, and Delete Brush deletes them
A Shift-click or Ctrl/Cmd-click on a brush in the Brushes panel used to
apply the brush, as a plain click does. It now selects brushes without
applying one. Shift-click adds the brushes from the last brush clicked
without Shift (else the highlighted brush) to the clicked one, in the
order the panel shows them. Ctrl/Cmd-click adds the clicked brush to the
highlighted brushes or removes it. The panel highlights the selected
brushes, and the bottom bar's Delete Brush and the panel menu's
Duplicate Brush and Delete Brush act on all of them. A plain click
works as before and ends the multi-selection, and so does selecting
other art, another document or another current brush.

brush.delete and brush.duplicate also take {names: [name...]}. Each
call is one undo step, and an unknown name fails before anything
changes. brush.duplicate answers {names} for a names list.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 15:14:41 +07:00
dotCooCoo 77f39084de Blend: Window › Blend opens a Blend panel with the blend's spacing, orientation and commands
The reference app has a Blend panel, opened with Window > Blend, that
makes and edits blends in one place. VectorCraft had the Blend tool,
Object › Blend and the Blend Options dialog, but no Blend panel.

Window › Blend opens a panel with Make, the selected blend's Spacing and
Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand
and Release. Each control runs its Object › Blend command, so a change
of the selected blend's spacing or orientation shows on the canvas at
once and is one undo step. With two or more objects selected and no
blend, Spacing and Orientation set the options new blends start with,
as Blend Options does. Make is dimmed until two or more objects are
selected. Spacing and Orientation are dimmed until a blend or two or
more objects are selected, and the other buttons are dimmed until a
blend is selected. The Blend Options dialog's orientation toggles now
have names for screen readers.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
2026-10-17 15:14:41 +07:00
dotCooCoo ab1c386ca4 i18n: blend spine errors are translated in every complete language
The errors of the four blend spine commands (Move Spine Point, Reshape
Spine Segment, Add Spine Point and Delete Spine Point), which Direct
Selection, the Pen and the Anchor Point tool send, had no @msg row in
any catalog, so an error that reached the status bar, for example from
a Pen click, showed in English. The scan in
complete_languages_translate_every_message does not read messages
built with bad(), so that test did not report them.

Each of the 13 messages now has a row in the 16 catalogs that translate
every status and error message, in Bulgarian and in Simplified Chinese.
The rows that name the spine use each catalog's term for it. No English
string changes.

Docs: docs/target-app-parity.md.
2026-10-17 15:14:41 +07:00
BFlat 0887872228 Merge pull request #2658 from storytold/pr/image-trace-andeecollard
Image Trace: six fixes from @andeecollard (#2589, #2584, #2387, #2586, #2587, #2588)
2026-10-17 15:12:18 +07:00
BFlat abe714bbae Merge pull request #2397 from marcoarnulfo/fix/freeform-gradient-color-effects
Color effects recolour the points of a freeform gradient
2026-10-17 15:11:45 +07:00
BFlat 86e24874b1 Merge pull request #2410 from jmverges/fix/2405-envelope-mesh
#2405: an envelope's mesh in an .ai isn't read as art
2026-10-17 15:11:30 +07:00
BFlat e0525e24ff Merge pull request #2420 from dotCooCoo/fix/ai-type-keeps-opacity
.ai import: type keeps its own opacity and blend mode
2026-10-17 15:11:15 +07:00
BFlat 6603256286 Merge pull request #2425 from dotCooCoo/feat/shift-reshapes-segment-perpendicular
Paths: Shift keeps a reshaped segment's handles perpendicular and equal in length
2026-10-17 15:11:01 +07:00
BFlat 0131a75fd8 Merge pull request #2431 from dotCooCoo/feat/shift-enter-keeps-field
Fields: Shift+Enter applies a panel field's value and keeps the field active
2026-10-17 15:10:46 +07:00
BFlat e0856dd574 Merge pull request #2436 from marcoarnulfo/fix/ai3-paths-import
EPS import: an Illustrator 3 file without layers opens its paths on one layer (fixes storytold/photocraft#6036)
2026-10-17 15:10:31 +07:00
BFlat 2a8efd7aee Merge pull request #2438 from dotCooCoo/fix/paragraph-reset-panel-hyphenation
Paragraph: Reset Panel also resets the Hyphenation options
2026-10-17 15:10:21 +07:00
BFlat c0c497f4c3 Merge pull request #2443 from dotCooCoo/fix/smart-blur-radius-edge-search
Effects: Smart Blur's Radius widens the edge search in Edge Only and Overlay Edge
2026-10-17 15:10:05 +07:00
BFlat 14d12ac5bd Merge pull request #2444 from marcoarnulfo/fix/ai-compound-holes
AI import: a compound path whose subpaths' group is marked like a look keeps its holes
2026-10-17 15:09:49 +07:00
BFlat 4cd0757512 Merge pull request #2445 from dotCooCoo/feat/eps-ccittfax
EPS: images encoded with CCITTFaxDecode draw
2026-10-17 15:09:33 +07:00
BFlat 86bd11a271 Merge pull request #2448 from marcoarnulfo/fix/cmyk-image-alpha-smask
PDF export: a CMYK image's own soft mask is written as its /SMask, not a luminosity mask
2026-10-17 15:09:18 +07:00
BFlat b7ffd4a91e Merge pull request #2454 from marcoarnulfo/fix/recolor-wheel-handles
Recolor Artwork: dragging a wheel handle of CMYK art keeps the linked handles in place
2026-10-17 15:09:04 +07:00
BFlat 5e88acde4d Merge pull request #2456 from EricKuck/fix/mesh-patch-backing
Gradient mesh: an opaque patch is backed by its outline, so patches that narrow to slivers leave no holes
2026-10-17 15:08:53 +07:00
BFlat 582d527612 Merge pull request #2458 from marcoarnulfo/fix/missing-fonts-folder
Missing Fonts: a folder the search refuses says why in the dialog, not "Not found"
2026-10-17 15:08:41 +07:00
BFlat 82047aca3e Merge pull request #2461 from dotCooCoo/feat/open-postscript-files
File formats: PostScript (.ps) and .epsf files open and place
2026-10-17 15:08:27 +07:00
BFlat 9cf044b9f4 Merge pull request #2464 from marcoarnulfo/fix/dup-text-font
Type: emptying the text while editing keeps its character style
2026-10-17 15:08:18 +07:00
BFlat 307bb3b3cc Merge pull request #2467 from dotCooCoo/feat/release-guide-double-click
Guides: a Cmd/Ctrl+Shift double-click releases the guide under the pointer
2026-10-17 15:08:01 +07:00
BFlat c10677f385 Merge pull request #2479 from dotCooCoo/fix/character-reset-panel-all-attributes
Character: Reset Panel resets every attribute the panel shows except the font and size
2026-10-17 15:07:49 +07:00
BFlat 76f8f68849 Merge pull request #2483 from dotCooCoo/feat/layers-save-selection-menu
Layers: the panel menu's Save Selection submenu saves, lists and updates saved selections
2026-10-17 15:07:34 +07:00
BFlat 3e65dd7cf3 Merge pull request #2488 from webdevtodayjason/fix/1968-transparency-grid
View: Show Transparency Grid is saved with the document
2026-10-17 15:07:24 +07:00
bflatastic dc64a0c8e1 Docs: the ROADMAP's 2026-10-17 row includes the fourth batch 2026-10-17 14:47:17 +07:00
andeecollardandClaude Opus 5.5 d15eae5fa5 Image Trace: collapsed loops keep few segments, so Paths is monotone
A small closed loop whose fit collapsed to a single cubic (fit_closed),
or a piece returning to its own junction with fewer than two fitted
cubics (fit_piece), kept every positioned point as a straight segment.
A looser Paths collapses more loops, so it could give more segments
than a tighter one.

Such a loop now tries the fewest segments that still hold it, each only
if the result keeps an area (more than 0.25 px^2) and stays within
1.5 * max(tolerance, 0.5 px) of the points both ways: the fit at up to
three somewhat tighter tolerances (what a slightly higher Paths gives);
for a closed loop then two cubics split at its start and its farthest
point, then the outline simplified at the tolerance and finer. The
positioned points stay the last resort, as before.

Test (code-drawn): a_higher_paths_never_gives_fewer_segments sweeps
Paths 0/25/50/75/100 over 32 seeded 48 px images of soft blobs plus
pixel noise in Color and Black and White at Noise 1 and 3. Before, six
runs had fewer segments at a higher Paths (e.g. 125 -> 79 from Paths 0
to 25); after, none. One image (seed 16, Black and White: 140 -> 136
from Paths 0 to 25, with or without this change) has another cause and
is left out of the sweep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 14:47:03 +07:00
andeecollardandClaude Opus 5.5 1d72a6f895 Image Trace: the notch rule reads only its own piece
Where the pixel at a sharp corner falls to the other colour, the outline
cuts the corner in two one-pixel steps, and between two straight edges
of three cracks or more subpixel::outline makes those steps one point
(a notch). Whether the edges were long enough was measured on the whole
loop. An edge can run on past a junction in one region's loop while
the region across it, whose loop turns at that junction, sees a shorter
edge: one side made the notch and the other did not, so the shared
piece got different points on each side and the abutting curves left a
gap.

The notch now also requires the three cracks of each edge next to it to
lie in the piece (no junction among them), as the corner-pixel shift
correction above it already does.

Test (code-drawn): noisy_colour_images_leave_no_sample_uncovered traces
20 seeded 36 px images of soft overlapping blobs in four colours plus
pixel noise (Color, 5 colours, Noise 1 and 2, Paths 0/50/100) and needs
every one of five sample points per pixel to lie in some path: 93
uncovered before (seeds 9, 11, 13), 0 after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 14:47:03 +07:00
andeecollardandClaude Opus 5.5 f8af51a341 Image Trace: same sub-pixel shift from either side of an edge
Layers::shift read a crack's sub-pixel shift from its inside pixel,
looking for a thin run on the inside before one on the outside. Where
both sides are thin (a one- or two-pixel sliver beside another, as in
soft gradients and noisy scans) the two regions of the crack got
different shifts, so their shared piece got different points and the
abutting curves left slivers that no path covers.

The shift is now the mean of the readings from both sides,
(s(c) - s(c flipped)) / 2, which is exactly antisymmetric in floating
point (Crack::flipped gives the crack seen from its other side).

Tests (code-drawn images):
- a_crack_between_thin_slivers_has_the_same_shift_from_either_side:
  every crack's shift is the exact negative of its flipped crack's, on
  a noisy ripple of 1-2 px columns at three thresholds.
- shared_boundaries::thin_slivers_on_both_sides_of_an_edge_leave_nothing_uncovered:
  a 48 px noisy ripple in Black and White at thresholds 140/157/170 and
  Paths 0/50/100, sampled on the shared-boundary check's 4x4 grid per
  pixel: 482-1,228 uncovered points per trace before, 0 after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 14:47:03 +07:00
andeecollardandClaude Opus 5.5 caabf08f3e Image Trace: Shapes keep abutting neighbours watertight
With Method Abutting and Shapes on, a region recognised as a shape had its
outline replaced by the exact shape, but the region across that edge kept
its fitted curve unless it was a hole filled by one colour, and even then
the hole was recognised on its own, with its region's combined hardness.
A shape touching a junction (a third colour, or its own colour at a
corner) or the image's border was replaced on one side only, so abutting
colours parted or overlapped along the shared edge, and a square on the
image's corner could reach past the frame.

Cause: Layers::layer recognised an island with no holes
(`[only] => shapes::recognise`) regardless of what lay around it, and a
hole only by `one_colour_within`, each from its own outline and hardness.

Fix: under Abutting a shape is applied only where both faces take it
(`Layers::shared_shape`): the loop has no junction on it and does not
touch the border, the island has no holes and the hole holds one colour,
and the shape stays inside the image. The shape is recognised from the
outline turned one way and started at its leftmost anchor, with that
loop's own hardness, so the island and the hole it fills get the very same
shape. Overlapping is unchanged.

Test: shapes_keep_abutting_neighbours_watertight traces three code-drawn
anti-aliased colour images (a disc in a square plus a disc across its
edge, shapes on the border, tilted tiles) and checks every outline lies
within 0.001 px of a neighbour and a 4 x 4 per-pixel grid is covered
exactly once. Before: gaps of 0.50, 15.1 and 0.73 px, 183 and 276 points
uncovered or doubled; after: 0 px, 0 points. Isolated shapes are still
recognised.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 14:47:03 +07:00
Marco Arnulfo 2f24495ffe Color effects recolour the points of a freeform gradient (fixes #2390) 2026-10-17 14:46:21 +07:00
andeecollardandClaude Opus 5.5 989768207f Image Trace: Flat Logo traces line art instead of a blank result
Flat Logo found its colours only from solid pixels (a uniform 3 x 3
neighbourhood). Line art on paper has no solid area but the paper's: a
stroke one or two pixels wide never fills one, so its colour was a handful
of pixels among tens of thousands of white and was cut as noise (under
0.1% of the solid pixels). The palette came back empty and trace_logo
returned an empty TraceResult, which imageTrace.make reported as a success
with paths 0, anchors 0, colors 0.

Cause: logo::palette::auto_palette had no way to find a colour that has no
solid area, and logo::pipeline::trace_logo treats an empty palette as
nothing to trace.

Fix: when the solid pixels give no colour but white, auto_palette reads
the colours from the strokes' cores instead (line_art_palette): the opaque
ink pixels at least as far from white as each of their four neighbours,
the middle of each stroke where it is least blended with the paper.
Two guards keep that fallback to line art:
- Ink is a colour at least 48 below white in one channel, and the page
  must have ink on at least 1% of its opaque pixels. Light grain and a few
  sparse marks on otherwise blank paper stay blank, as plain white does.
- The lighter shades along a stroke's anti-aliased edge are local maxima
  of their own; a colour that lies on the line from white to a darker
  colour of the palette (within 20) is a blend of that ink with the paper
  and is folded into it (fold_fringes), the ink keeping its colour.
The rest of the pipeline (unmixing, the flat-colour check and its NotFlat
refusal) is unchanged, and images with a solid colour other than white
never reach the fallback.

Tests (code-drawn inputs):
- thin_line_art_on_paper_is_traced_not_blank: four 2.4 px dark strokes
  and two red marks on white. Before: palette [], no paths. After: dark
  and red paths along the middle of every stroke and mark.
- anti_aliased_coloured_lettering_has_only_its_inks: rings, slants and
  bars 1.6 px wide in red, blue and green. Without the fold: six colours
  besides white; after: exactly the three inks.
- sparse_marks_on_paper_are_not_promoted_to_colours: 24 grey 12 x 2 px
  marks of 239, 223 or 191 on a 256 x 256 page (under 1% ink). Without
  the guard: palette [white, #efefef]; after: empty palette, no paths.
- grainy_blank_paper_has_no_ink: paper grain 243-255 traces to nothing.
- edge_shades_fold_into_their_ink_and_other_colours_stay (unit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 14:46:21 +07:00
andeecollardandClaude Opus 5.5 6bbf30e52a Image Trace: a simpler contour walk with no precomputed direction table
contour::trace_mask built a table of outgoing directions for every
pixel corner in a raster pass and then followed it. It now works out
each turn during the walk from the two pixels ahead of the current
corner: right if the one ahead on the right is outside, straight if the
one ahead on the left is, else left (so at a saddle it still turns
right and diagonal pixels stay apart). Loop starts are found by
scanning the pixels for a top edge on the outline, with one flag per
pixel for a top edge already walked. The walk is bounded by the grid
size, and a mask of the wrong length now gives no loops instead of
failing an assert_eq!.

The output is identical: the_walk_equals_the_stored_direction_walk
compares it with a test-only copy of the previous walk on all 65,536
4x4 masks and 400 seeded 23x17 masks.

Time (best of 7, release, 4 cores): the walk alone on 2000x2000 masks
19.1 vs 26.6 ms (smooth pattern), 81.3 vs 93.2 ms (12% random pixels),
223.1 vs 238.1 ms (50% random); whole 1000x1000 traces 73.1 vs 77.1 ms
(Black and White), 249.6 vs 251.7 ms (Color, 6 colours), 214.1 vs
218.6 ms (Color, 6 colours, Noise 1), same segment counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 14:46:21 +07:00
jmvergesandClaude Opus 5.5 1946ffca9c #2405: an envelope's mesh in an .ai isn't read as art
An envelope is written on `%_` lines as a group holding the mesh that shapes
it, with the distortion (`AIDeformStyle`, `DeformValue`) in the group's
dictionary; the distorted art the PDF part draws follows on plain lines. The
reader kept that group, so the mesh drew as a grey gradient mesh behind the
art. A group on `%_` lines that holds only meshes and carries a distortion is
now dropped; a mesh with no distortion is still art.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P6znVFRqGqvB9f7njwZ46r
2026-10-17 14:46:20 +07:00
dotCooCoo 3f81c70ade .ai import: type keeps its own opacity and blend mode
A .ai or EPS opened from its editing data lost the opacity and blend
mode of its text objects. text_slot made the slot without the graphics
state's opacity and blend mode, which paths, meshes and images take, so
an Xy before a text object was lost. An Xy after it reached the slot,
but fill_slots then put type made from the text document in the slot's
place with only its visibility and name, and style_marker dropped the
transparency that a text object with an appearance carries on its look.

text_slot now takes the state's opacity and blend mode. fill_slots
gives made type the slot's opacity, blend mode, isolation and knockout.
The page's type gets them too, in place of its own transparency when
that is none, the slot's, or the slot's combined with that of the
objects around it; otherwise the page's type and the strokes around it
keep their own (a fill's, for example). When the type has transparency
and comes with other art (the strokes the page draws around it, or page
type that goes with it), a group of them takes the transparency. A text
object with an appearance takes the transparency from its look; where
the look draws more than the type and has transparency, the look and
the type go into a group that has it, and that group is not taken as
the look of a later object. The import fuzz's .ai tokens now include a
commented text object, 1 /FreeUndo , and a group's state marker
(6 () XW).

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 14:46:20 +07:00
dotCooCoo f3072fc748 Paths: Shift keeps a reshaped segment's handles perpendicular and equal in length
Shift pressed while dragging a segment had no effect on the curve:
Direct Selection's curved-segment and spine-segment drags, the Anchor
Point tool and the Pen with Alt sent the pointer offset alone, and the
segment bent freely. The reference app keeps the handles in the
perpendicular direction and of equal length while Shift is held.

path.reshapeSegment and object.blend.spine.reshapeSegment take
perpendicular (default false, which keeps today's behavior). With it,
both of the segment's handles stand at right angles to the line between
its anchors, on the same side and at the same length, and the point at
t lies as far from that line as the moved point does. A smooth anchor
at either end stays smooth, as it does in a drag without perpendicular,
and a segment whose anchors coincide bends as it does without
perpendicular. With Direct Selection, Shift pressed once a curved
segment's drag has started sends perpendicular; Shift held at the press
still toggles the path's selection, as before. Direct Selection's
spine-segment drag and the Anchor Point tool's segment drag send Shift
as perpendicular too, so the Pen with Alt does as well. A straight
segment dragged with Direct Selection already moves at 45° steps with
Shift.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 14:46:20 +07:00
dotCooCoo 0e26b258eb Fields: Shift+Enter applies a panel field's value and keeps the field active
Shift+Enter in a panel or Control bar field ended its editing, as Enter
does. The reference app's default keyboard shortcuts list "Apply value
and keep text box active" under Use panels and "Apply a value and keep
focus in edit field" under Transform panel, both Shift+Enter on Windows
and Shift+Return on macOS.

- A focused single-line field of the shared widgets (num_field,
  mixed_field, plain_field, range_field, spin_field, spin_field_auto,
  spin_plain, text_field, exact_text_field, opt_field and hex_field)
  takes Shift+Enter before its text box reads it, applies the text as
  Enter does and keeps the focus. In the next frame it shows the value
  as it now is, all selected, so typing replaces it.
- A text box of several lines keeps Shift+Enter as a line break.
- While a dialog is open, Shift+Enter applies the field's value and
  confirms the dialog, as before. The app sets widgets::set_dialog_open
  at the start of each frame.

Docs: docs/target-app-parity.md.
2026-10-17 14:46:20 +07:00
Marco Arnulfo 877aac130f EPS import: an Illustrator 3 file without layers opens its paths on one layer (fixes storytold/photocraft#6036) 2026-10-17 14:46:20 +07:00
dotCooCoo d7026367f7 Paragraph: Reset Panel also resets the Hyphenation options
The Paragraph panel menu's Reset Panel turned Hyphenate off but left
the Hyphenation options: Words Longer Than, After First, Before Last,
Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized Words. A
paragraph kept its custom hyphenation limits after a reset.

Reset Panel now also sets those six options to the values of
HyphenationOptions::default(), in the same text.setFormat call. The
reset paragraphs then save no hyphenation options.
2026-10-17 14:46:20 +07:00
dotCooCoo e37fbe3849 Effects: Smart Blur's Radius widens the edge search in Edge Only and Overlay Edge
Smart Blur's Edge Only and Overlay Edge modes marked a pixel when one
of its four nearest pixels differed from it by more than Threshold,
and Radius changed only the blur. The reference app's documentation
says that Radius sets the size of the area searched for dissimilar
pixels.

A pixel is now on an edge when one of the pixels the search reads
within Radius differs from it by more than Threshold and its
channels, alpha included, add up to at least that pixel's. The search
picks its neighbors as the Normal blur does, over the whole pixels of
Radius and within it, and always reads the four nearest pixels.
A color step gives a line about as wide as Radius on the side with
the larger sum, and a Radius of one pixel or less gives the same
edges as before. The edge modes' reach() includes the blurred copy
that a wide Radius reads.
On a 2,000 by 2,000 px image, Edge Only takes 0.49 s at Radius 10 and
0.34 s at Radius 100, and Normal takes 0.73 s and 0.39 s. Normal
draws the same pixels as before.

Docs: docs/target-app-parity.md.
2026-10-17 14:46:20 +07:00
Marco Arnulfo 9739eeb333 AI import: a compound path whose subpaths' group is marked like a look keeps its holes (fixes #980) 2026-10-17 14:46:20 +07:00
bflatastic 77f7f02acc Docs: file-format-parity keeps one EPS and one PostScript row 2026-10-17 14:46:20 +07:00
Marco Arnulfo 025ce255ae PDF export: a CMYK image's own soft mask is written as its /SMask, not a luminosity mask (fixes #2159) 2026-10-17 14:43:14 +07:00
Marco Arnulfo 8e69146160 Recolor Artwork: dragging a wheel handle of CMYK art keeps the linked handles in place (fixes #1733) 2026-10-17 14:43:14 +07:00
EricKuckandClaude Opus 5.5 7add248dc3 Gradient mesh: an opaque patch is backed by its outline, so patches that narrow to slivers leave no holes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:43:14 +07:00
Marco Arnulfo cb968353a5 Missing Fonts: a folder the search refuses says why in the dialog, not "Not found" (part of #2200) 2026-10-17 14:43:14 +07:00
dotCooCoo 08dc614938 File formats: PostScript (.ps) and .epsf files open and place
The EPS reader read PostScript by any name, but the Open and Place
dialogs hid .ps and .epsf files, and a .ps or .epsf file whose header
is only %! could not be opened or placed ("not a format VectorCraft
reads"). The reference app lists .eps, .epsf and .ps under Open and
Place.

- A read-only PostScript format (ps) joins the format table. A .ps
  file opens and places through the EPS reader and reports the
  ps format. document.export does not write .ps; file.print does.
- .epsf is an EPS extension.
- OPEN_EXTS and PLACE_EXTS list epsf and ps, so the dialogs, the CLI
  help and the MCP open_file description list them.
  UNASSOCIATED_EXTS lists them too: the macOS bundle declares neither.
- A .ps or .epsf file whose header is only %! now pastes as EPS when
  copied in a file manager, as one with a %!PS header already did, and
  clipboard.importEps reads PostScript whose header is only %!, which
  it rejected before.

Docs: docs/mcp.md, docs/file-format-parity.md (.ps row),
docs/target-app-parity.md.
2026-10-17 14:43:14 +07:00
dotCooCoo a070d83be3 EPS: images encoded with CCITTFaxDecode draw
The PostScript interpreter that reads EPS and PostScript files had no
CCITTFaxDecode filter. Reading a file whose image data went through it
stopped at an error naming the filter, and the file opened from a
fallback, such as its preview or the art drawn before the image, or
did not open.

CCITTFaxDecode now decodes CCITT Group 4 (ITU-T T.6) and Group 3
one-dimensional and mixed (ITU-T T.4) data to rows of 1-bit samples.
It reads K, Columns, Rows, EndOfBlock, EndOfLine, EncodedByteAlign and
BlackIs1 (PLRM 3rd ed., §3.13; ISO 32000-2, §7.4.6). With EndOfBlock
the data ends at its EOFB or RTC regardless of Rows, and the RTC of
mixed Group 3 data, six EOLs each with a tag bit, is found by the
filter. Damaged data keeps the rows decoded before it, and damage in
the first row is an ioerror. Columns outside 1 to 65,535 and Rows
below 0 are a rangecheck, and data that would decode to more than
256 MiB is refused. hayro-ccitt 0.3.0, already in Cargo.lock through
hayro-syntax, becomes a dependency of vectorcraft-eps.

The tests encode their images in the test, with the fax crate's
Group 4 encoder and T.4 run codes (a dev-dependency, already in
Cargo.lock through tiff), and fail without the change. The import fuzz
tests read a Group 4 and a Group 3 image coded by hand from the T.4
and T.6 tables, read the same file with random damage, and add the
filter and its parameters to the hostile programs.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-17 14:43:14 +07:00
Marco Arnulfo 63b95c8dd3 Type: emptying the text while editing keeps its character style (fixes #2119) 2026-10-17 14:43:13 +07:00
dotCooCoo b275eb04d0 Guides: a Cmd/Ctrl+Shift double-click releases the guide under the pointer
A double-click on a ruler guide with the Selection, Direct Selection or
Group Selection tool did nothing, whatever keys were held. A guide could
be released only with View › Guides › Release Guides.

With the Selection, Direct Selection or Group Selection tool, a
double-click with Cmd/Ctrl and Shift held now runs view.guides.release
on the ruler guide under the pointer (index) or, when no ruler guide
is there, on the guide path under it (ids). Nothing is released while
guides are hidden or locked, and a double-click without both keys does
what it did before.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 14:43:13 +07:00
dotCooCoo 4650ccbe26 Character: Reset Panel resets every attribute the panel shows except the font and size
The Character panel menu's Reset Panel reset tracking, leading,
kerning, baseline shift, the scales, rotation, underline,
strikethrough and All Caps, but left Small Caps, superscript or
subscript, Character Alignment, Proportional Metrics and Digits at
their custom values.

Reset Panel now also sets those five to their defaults in the same
text.setFormat call, with the values taken from CharStyle::default().
It resets them in the same characters as the other attributes: those
of the selected type, or, while the Type tool edits text, its
selection (all of that text at a caret). It resets them whether or
not Show East Asian Options and Show Indic Options (Preferences ›
Type) are on.
2026-10-17 14:43:13 +07:00
dotCooCoo 4f1e4979fc Layers: the panel menu's Save Selection submenu saves, lists and updates saved selections
The Layers panel menu had no saved-selection items, and the Select
menu was the only menu that listed saved selections. The reference
app's help also names a Save Selection menu in the Layers panel that
lists the saved selections and holds Save Selection, Update Selection
and Edit Selection.

The Layers panel menu now has a Save Selection submenu after Locate
Object. It holds Save Selection…, Update Selection and Edit Selection…,
which are enabled and run as in the Select menu, and below them the
active document's saved selections. Choosing a name runs select.recall
with that name, as the Select menu's list does.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 14:43:13 +07:00
webdevtodayjason 3895694a9b View: Show Transparency Grid is saved with the document
Native files remember the grid like the saved view, so reopening restores it.
Toggling it is still view state: no undo step, document not marked modified.

Fixes #1968
2026-10-17 14:43:12 +07:00
BFlat e6b58f5142 Merge pull request #2649 from storytold/pr/batch-1017-i18n-docs
i18n and docs: rows and ROADMAP entry for the community PRs landed on 2026-10-17
2026-10-17 14:40:56 +07:00
BFlat afa1c992bf Merge pull request #2404 from marcoarnulfo/fix/transform-percent-size
Transform: a percentage typed alone in W or H scales the size it shows
2026-10-17 14:39:18 +07:00
BFlat 8548cf272d Merge pull request #2400 from marcoarnulfo/fix/transform-menu-flip-origin
Transform panel: the menu's Flip Horizontal and Flip Vertical keep the reference point
2026-10-17 14:38:41 +07:00
BFlat d890ab337d Merge pull request #2507 from dotCooCoo/fix/rgba-within-decode-limit
Images: sampling and recoloring decode images past 512 MB, up to 2 GB
2026-10-17 14:38:07 +07:00
BFlat c1f543dd75 Merge pull request #2567 from dotCooCoo/feat/artboards-duplicate-copy-cut-selected
Artboards: Duplicate Artboards, Copy and Cut take every selected artboard
2026-10-17 14:37:15 +07:00
BFlat dbef720363 Merge pull request #2590 from dotCooCoo/fix/i18n-variable-site-rows
i18n: file filters, preset names and mark styles are translated in every complete language
2026-10-17 14:36:58 +07:00
BFlat 67fbb3c18d Merge pull request #2597 from dotCooCoo/fix/save-as-template-extension
Templates: Save as Template always writes a file with a template extension
2026-10-17 14:36:45 +07:00
BFlat a2aa0fc303 Merge pull request #2617 from dotCooCoo/fix/effect-color-param-refuses-unreadable
Effects: effect.apply and effect.setParams refuse a color the effect can't draw
2026-10-17 14:36:33 +07:00
BFlat 99220d50e1 Merge pull request #2621 from dotCooCoo/fix/pattern-cache-keeps-decode-warning
Render: every raster export warns about an undecodable image inside a pattern
2026-10-17 14:36:21 +07:00
BFlat 770213b295 Merge pull request #2501 from dotCooCoo/fix/para-style-redefine-default-attrs
Type: redefining a text style back to a default updates the text that uses it
2026-10-17 14:36:04 +07:00
BFlat f72d5060f2 Merge pull request #2504 from dotCooCoo/fix/batch-data-set-file-names
Actions: Batch keeps files named after data sets inside the destination folder
2026-10-17 14:35:47 +07:00
BFlat 3c2fa5917d Merge pull request #2508 from dotCooCoo/fix/character-small-caps-super-subscript-buttons
Character: Small Caps, Superscript and Subscript buttons and menu items set the text
2026-10-17 14:35:29 +07:00
BFlat 7ddc8d9c8a Merge pull request #2513 from dotCooCoo/feat/ai-drop-shadow-darkness
.ai import: a Drop Shadow with Darkness comes in with its Darkness
2026-10-17 14:35:05 +07:00
BFlat 85baa19884 Merge pull request #2554 from dotCooCoo/fix/kerning-pairs-across-style-runs
Type: a change of paint, baseline shift or rotation on one character keeps the font's kerning
2026-10-17 14:34:51 +07:00
BFlat f4000408a2 Merge pull request #2574 from dotCooCoo/fix/svg-justify-all-letter-gaps
SVG export: justified and Mojikumi lines keep their letter spacing in live text
2026-10-17 14:34:32 +07:00
BFlat 2cdf8465b9 Merge pull request #2578 from dotCooCoo/feat/zh-hans-messages
i18n: Simplified Chinese translates status-bar and error messages
2026-10-17 14:34:16 +07:00
BFlat 8f42ac1892 Merge pull request #2580 from dotCooCoo/chore/type-parity-hyphenation-row
Docs: type-parity and the roadmap list the Hyphenation options as done
2026-10-17 14:34:05 +07:00
BFlat ab2f745cd3 Merge pull request #2598 from dotCooCoo/fix/tiff-planar
TIFF: planar grayscale files with extra channels, and planar CMYK files, open and place
2026-10-17 14:33:49 +07:00
BFlat 7a97db06b7 Merge pull request #2602 from dotCooCoo/test/devanagari-shaping
Type: a click in a Devanagari syllable goes to its nearer end, and shaping is tested
2026-10-17 14:33:35 +07:00
BFlat 17a1c9835b Merge pull request #2608 from dotCooCoo/feat/dataset-import-tabs-no-name
Variables: Import Data Sets… reads tab-separated files and files without a name column
2026-10-17 14:33:24 +07:00
BFlat 0990c7bbd3 Merge pull request #2607 from dotCooCoo/feat/print-preset-by-name
Print: Print, Print Preview and Print Setup take a print preset by name
2026-10-17 14:33:02 +07:00
BFlat 41d79128f5 Merge pull request #2623 from dotCooCoo/feat/swatch-alt-click-stop
Swatches: Alt-click on a swatch recolors the selected gradient stop
2026-10-17 14:32:47 +07:00
BFlat eff0baad56 Merge pull request #2611 from dotCooCoo/fix/icon-buttons-accessible-names
Accessibility: buttons, toggles, checkboxes and sliders have names for screen readers
2026-10-17 14:32:32 +07:00
BFlat f7d1c354fd Merge pull request #2547 from vangel76/fix/2168-persist-presets-profiles
Persistence: saved PDF presets and loaded ICC profiles survive a restart (#2168, #2174)
2026-10-17 14:32:20 +07:00
BFlat 66374d0835 Merge pull request #2546 from vangel76/fix/2165-mcp-cancel-export
MCP: cancel long exports, answer while one runs, report progress (#2165, #2171)
2026-10-17 14:31:57 +07:00
BFlat e5745c4fc1 Merge pull request #2522 from vangel76/fix/2162-rgb-in-cmyk-warning
PDF export: report RGB colours and images in a CMYK document (#2162)
2026-10-17 14:31:40 +07:00
BFlat 4eba49ff48 Merge pull request #2543 from vangel76/fix/2167-inspect-scope
MCP: scope doc_inspect by ids, kind and page (#2167)
2026-10-17 14:31:24 +07:00
BFlat 67a4b8fd1d Merge pull request #2571 from theromis/fix/eps-editable-illustrator-import
Fix Illustrator EPS import to preserve editable vector artwork
2026-10-17 14:31:09 +07:00
bflatastic ae82ed6500 Docs: the ROADMAP records the community PRs landed on 2026-10-17 2026-10-17 14:30:04 +07:00
bflatastic 40dd19d17a i18n: Danish, Dutch, Indonesian and Simplified Chinese rows for the strings this batch adds 2026-10-17 14:09:46 +07:00
Marco Arnulfo 8148d024ea Transform: a percentage typed alone in W or H scales the size it shows (fixes #2401) 2026-10-17 13:52:06 +07:00
Marco Arnulfo 68c9d440ab Transform panel: the menu's Flip Horizontal and Flip Vertical keep the reference point (fixes #2398) 2026-10-17 13:52:06 +07:00
dotCooCoo bde7140396 Images: sampling and recoloring decode images past 512 MB, up to 2 GB
rgba_within checked an image's size as RGBA against the allowance it
was given (2 GB on the desktop), then decoded the image within the
image crate's default limit of 512 MB. An image whose decoded pixels
take more than 512 MB, such as an RGBA image of 11,586 × 11,586 pixels,
was refused although the canvas draws it: the Eyedropper picked up
nothing from it, and Edit Colors, Recolor Artwork, print separations
and the color adjustment effects in export and Expand Appearance left
its pixels as they were.

The decoder's limit is now the allowance that rgba_within is given.
An image decoded as RGBA is no longer copied before it is used.
2026-10-17 13:52:06 +07:00
dotCooCoo cdb0bbe315 Artboards: Duplicate Artboards, Copy and Cut take every selected artboard
With several artboards selected, the Artboards panel menu's Duplicate
Artboards copied only the active one, and Copy and Cut with the
Artboard tool took only the tool's active artboard.

artboard.duplicate, artboard.copy and artboard.cut now take `indices`,
and without `index` or `indices` they act on the artboards the
Artboard tool has selected. Duplicate Artboards passes the selected
artboards and selects the copies. Duplicate and Cut are one undo step
each, and Paste adds every copied artboard back, with its art, in one
undo step. The copies keep their places relative to each other,
together right of the last artboard. A cut fails when it names every
artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:06 +07:00
bflatastic 32e22891d6 i18n(ja): a radial gradient kind reads 円形, as Radial Gradient does 2026-10-17 13:52:06 +07:00
dotCooCoo 14de645441 Templates: Save as Template always writes a file with a template extension
file.saveAsTemplate kept a name whose extension named another format
and wrote the template into it: {path: "card.png"} wrote .vctemplate
JSON to card.png, and card.pdf typed in the desktop save panel got it
too. file.saveAs {format: "ait"} answered that document.export writes
other formats, but document.export does not write .ait either.

file.saveAsTemplate now replaces another format's extension (card.png
is saved as card.vctemplate, or card.ait for format: "ait") and adds
the extension to a name whose extension names no format. The desktop
panel adds the template's extension to any name that does not end in
a template extension (card.pdf.vctemplate), as Save As adds its own.
Save and Save As still refuse ait, and the error now names
file.saveAsTemplate.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:06 +07:00
dotCooCoo b9c3dd4519 Effects: effect.apply and effect.setParams refuse a color the effect can't draw
effect.apply and effect.setParams stored any string as the color of
Drop Shadow, Inner Glow, Outer Glow and Shift to Color. When the
effect could not read it, it drew its default color with no message,
so a typo such as #ff00 in the Drop Shadow dialog turned the shadow
black.

The commands now refuse such a color before they edit, with
"`color` must be a #rrggbb color", and the document and its undo
history stay as they were. Hex colors, the five named colors,
[r, g, b] and color objects still pass. The check runs in the
commands, so the app, command.batch steps and replayed journals get
it too. The effect dialog's OK shows the error and keeps the dialog
open, with Preview on or off. Saved documents open as before.

Docs: docs/mcp.md (effect params).
2026-10-17 13:52:06 +07:00
dotCooCoo b9d370aeff Render: every raster export warns about an undecodable image inside a pattern
A raster export warns that "an image couldn't be decoded and is
missing from the picture" when the count of failed decodes rose while
it drew. A pattern fill draws from a tile kept for each thread, and a
tile taken from that cache draws none of its art. Only the export that
made the tile counted the image, and later exports of the document gave
no warning although their pictures lacked the image too. The raster
kept for a blurred or filtered object had the same fault: the renderer
that draws pattern tiles keeps those rasters from one tile to the next,
so a tile drawn again took such an object from its raster and counted
nothing.

A kept tile and a kept effect raster now store how many decodes failed
on their thread while they were made, and each draw from them adds that
number to the count again.
2026-10-17 13:52:06 +07:00
dotCooCoo 69a90f6033 i18n: file filters, preset names and mark styles are translated in every complete language
Labels that the UI looks up through a variable or a context (#1259) had
rows only in the Bulgarian, Czech, Greek, Spanish, Finnish, French,
Polish, Brazilian Portuguese and Swedish catalogs. In German, Hungarian,
Italian, Japanese, Korean, Russian, Ukrainian, Vietnamese and both
Chinese, the UI showed most of them in English: the file dialogs' filter
names, the status bar's render and UI timings, New Document's preset
name, and the contributor tooltips with their plurals. The Printer mark
type list showed English "Roman" in each of those languages except
Hungarian, and in Japanese, Korean, Vietnamese and both Chinese it
showed the language's name for Japanese.

Those catalogs get the rows, each next to its related entry. German also
gets rows for the 3D gizmo's light, which the UI showed as "Hell"
(bright), and for Grain's Regular type, which the UI showed in English.
complete_catalogs_translate_render_site_labels requires the rows those
catalogs get and the light's row in every complete catalog, and checks
German's Regular row.

Docs: docs/localization-parity.md, docs/target-app-parity.md.
2026-10-17 13:52:06 +07:00
dotCooCoo b9b681c748 Type: redefining a text style back to a default updates the text that uses it
A style captured from text left out the attributes that the text's
JSON skips at their defaults, such as the Hyphenation options, the
Composer, tab stops, Auto kerning and the character alignment.
Redefining a paragraph or character style from text set
back to the defaults therefore did not reach the text using the
style: a paragraph that had the old Hyphen Limit or Single-line
Composer from the style kept it. setAttrs dropped a default value it
was given in the same way.

New and Redefine now capture every attribute of the selected text,
and charStyle.setAttrs and paraStyle.setAttrs keep a default value.
When a style changes, text whose value equals the old definition's
takes the new value, a default included. Local overrides on text that
uses a style other than the Normal styles stay, and an attribute that
setAttrs leaves out still leaves the text's value alone. The Character
Styles and Paragraph Styles panels compare the attributes a style holds
with the same attributes of the selection. A paragraph style made from
type with Auto alignment no longer shows "+" on that type.

Two existing behaviors change. Applying a style made with New or
Redefine from text, without Clear Overrides, now also sets the
attributes that text had at their defaults, such as Auto kerning, no
OpenType features, the Every-line Composer, no tab stops and the
default Hyphenation options. Redefining [Normal Paragraph Style] or
[Normal Character Style] from text gives every paragraph or run that
uses it those attributes too, so local values of them on that text are
reset.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 78330cfa2f Actions: Batch keeps files named after data sets inside the destination folder
Batch over data sets names each output file after the document and the
data set, as the reference app's File + Data Set Name option does. It
joined that name onto the destination folder as it was, and a data set
name can hold any character. A data set named a/b failed to save, and
one named a/../../x wrote its file outside the destination folder. On
Windows, one named c\d also failed to save, and one named e:f left an
empty file.

The data set's name now goes through file_part, which Export for
Screens uses for its file name prefix and a format row's suffix, so
output file names have - in place of /, \ and : from a data set's name.
When two data sets then give the same name, compared without case, the
later ones get -2, -3 and so on, as artboard file names do, through a
distinct_names helper split out of unique_file_names. This covers the
folder destination and Override Save/Export.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 187a75ca3f Character: Small Caps, Superscript and Subscript buttons and menu items set the text
The Character panel's Small Caps, Superscript and Subscript buttons
were disabled, with "(on the roadmap)" tips, and always drawn off, and
the panel menu's items of the same names were disabled, although
text.setFormat and text.setRangeStyle already set smallCaps and
position. The reference app applies all three from the Character
panel menu.

The three buttons and menu items now turn small caps and the
superscript or subscript position on and off for the selected type, or
for the text the Type tool has selected, in Document Setup's
proportions. Turning Superscript on turns Subscript off, and turning
Subscript on turns Superscript off. They show as on when the first
selected character has the attribute, as the All Caps, Underline and
Strikethrough toggles do.

docs/mcp.md did not mention smallCaps and position; it now describes
them for text.setFormat and text.setRangeStyle.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 13df37f9d1 .ai import: a Drop Shadow with Darkness comes in with its Darkness
An object whose art style had a Drop Shadow with Darkness (a csrc
other than 0 in the shadow filter's dictionary) came in as its drawn
look, with a warning that its appearance isn't editable yet. With a
Color shadow instead, the same object comes in as itself with its
fill, stroke and shadow.

The reader now reads such a shadow as Drop Shadow with shade
"darkness" and darkness set to the filter's dark value, a percentage
kept within 0 to 100, or 100 when the value is missing or not a
finite number. The shadow's other keys are read as for a shadow with Color,
and the object comes in as itself wherever it would with a shadow with
Color. A shadow with Color comes in with the same params as before.
The import fuzz's .ai tokens now include csrc and dark.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 8f71c94473 Type: a change of paint, baseline shift or rotation on one character keeps the font's kerning
Each style run was shaped on its own, so restyling one character, even
only its fill, dropped the font's kerning pairs at the new run
boundaries. A fill change on the V of 40 pt "AVAV" moved it 0.56 pt and
moved the characters after it 1.12 pt. Manual kerning on a character
also dropped the font's kerning with the character before it.

Adjacent runs that differ only in fill, stroke, underline,
strikethrough, overprint, character style name, leading, baseline
shift, rotation, Character Alignment, kerning, or tracking (as long as
both values leave standard ligatures on or both turn them off) are now
shaped together, and each glyph takes those attributes from its own
run. The font's kerning pairs apply after characters with automatic
kerning: a manual value replaces the pair after its character, and the
pair before it stays, except before a character that All Caps or Small
Caps sets as two letters (ß as SS). A change of font, size, scale or
OpenType features still shapes the characters apart, and a ligature or
a mark still does not join characters in different styles.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 8511d0321b SVG export: justified and Mojikumi lines keep their letter spacing in live text
SVG export wrote each style run of a line of live text as one <tspan>
and positioned the text again only after tabs and, on justified lines,
after word spaces. A viewer then spaced the characters inside the
<tspan> with the font's own advances, so it lost the letter gaps of a
Justify All line without word spaces, the gaps between the characters
of a justified Japanese line and the spacing that a Mojikumi Set
changes. On justified lines, the textLength of horizontally scaled type
also took in the justification space after it, so a viewer stretched
the glyphs into that space.

On a line that is justified or set with a Mojikumi Set, text_lines now
lays the text out again as point type without the Mojikumi Set to get
the advance a viewer gives each character. Each character that the
layout draws away from where a viewer would draw it starts a new
positioned <tspan>, and textLength and line anchors use the viewer's
widths. The last character of a horizontally scaled <tspan> takes its
width without the font's kerning against the next character. Other
lines, and lines written with Fewer <tspan> Elements, are written as
before.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 1e87e85ab7 i18n: Simplified Chinese translates status-bar and error messages
The Simplified Chinese catalog had 20 `@msg` rows, against 714 to 718
in each catalog listed in COMPLETE_MESSAGES, so with the interface in
Simplified Chinese most status-bar and error messages showed in
English.

- zh-hans.tsv gains the 694 missing `@msg` rows in one block with a
  header comment on its conventions: no final 。 unless the English
  ends with a period, a space between Chinese and adjacent Latin
  text, numbers or placeholders, and 、 between list items. The rows
  use the catalog's existing terms (画板, 图稿, 编组, 描边, 色板, 实时上色,
  图像描摹, 增效工具 ...).
- zh-hans joins COMPLETE_MESSAGES, so
  complete_languages_translate_every_message and the narrower message
  tests fail when a zh-hans message row is missing, and a new message
  needs a zh-hans row.
- The registry comment, docs/development.md (which also gains Greek,
  already in COMPLETE_MESSAGES), the zh-hans row of
  docs/localization-parity.md, and the interface-languages line of
  docs/target-app-parity.md (which also gains Czech, Greek, Hungarian,
  Swedish and Finnish) list Simplified Chinese among the languages
  that translate messages.
2026-10-17 13:52:05 +07:00
dotCooCoo c5d2297ef1 Docs: type-parity and the roadmap list the Hyphenation options as done
docs/type-parity.md and the Set type row of docs/roadmap.md still
listed the Hyphenation… options as missing, although the Hyphenation
dialog (#2310) sets them for each paragraph.

The type-parity row is now partial and names Words Longer Than, After
First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate
Capitalized Words. Justification… stays missing. The Set type row of
roadmap.md lists only the justification options as missing. Its score
is unchanged. docs/target-app-parity.md and docs/gaps.md are left as
they are.
2026-10-17 13:52:05 +07:00
bflatastic bf785840e5 Docs: file-format-parity keeps one PNG/TIFF row with both notes 2026-10-17 13:52:05 +07:00
dotCooCoo 5a3e833ec8 Type: a click in a Devanagari syllable goes to its nearer end, and shaping is tested
A syllable drawn with several glyphs that share one cluster, such as a
pre-base i matra and its consonant (कि), had a caret stop at the edge
of each glyph. A click just right of the matra, in the left half of
the syllable, put the caret after the syllable. byte_in_line now takes
one extent for each cluster, and type on a path compares the click
with the middle of the cluster, from the start of its first glyph to
the end of its last.

New tests with an installed OpenType Devanagari font check that क्ष
forms one conjunct glyph, that र्क draws the reph after its base, that
कि draws the matra before its consonant, that Ka with a nukta matches
the precomposed क़, and that each syllable is one cluster with the
arrow keys stopping at its ends. They print SKIPPED when no such font
is installed.

Docs: docs/localization-parity.md (Hindi row), docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo 5bdd9d539f Variables: Import Data Sets… reads tab-separated files and files without a name column
dataset.import read only commas between cells and refused a file whose
first header cell wasn't `name`. A value that didn't match its
variable's kind, and a repeated data set name, were reported with the
row's record number, which is not the file line after a blank line or a
quoted line break. A data set name over 255 characters gave no line.

- When the header row, read with tabs between cells, has a tab and no
  comma outside quotes, tabs separate the cells. A quoted cell may then
  hold tabs and commas.
- Without a `name` column every header cell is a variable, and the rows
  take the first free names Data Set 1, Data Set 2 and so on, as Capture
  Data Set and blank name cells do. A first header cell `name`, in any
  case, still holds the data set names.
- Value errors, a repeated data set name and a name over 255 characters
  give the file line the row starts on.
- The Variables panel's Import Data Sets… dialog also lists .tsv files.
- import_fuzz.rs imports random text, random bytes and mutated exports,
  comma- and tab-separated, into a document with bound text, visibility
  and graph variables, selects up to three of the imported data sets in
  turn, and exports every data set.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo a285efda46 Print: Print, Print Preview and Print Setup take a print preset by name
file.print, print.preview and print.setup took print settings only as
settings, so an agent had to read a preset with print.presets.list and
copy its settings into the call. They now take preset, the name of
[Default] or of a saved print preset in any case. The preset's settings
are used instead of the document's, settings applies over them, and
print.setup saves them with the document as one undo step. An unknown
name is a parameter error that lists the presets. The app's file.print
with params passes preset on to the engine.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo d5e44b321e Swatches: Alt-click on a swatch recolors the selected gradient stop
With a gradient stop selected, an Alt-click on a color swatch painted
the inactive Fill or Stroke, as it does with no stop selected. The
reference app's Default keyboard shortcuts page lists "Apply swatch
color to active (or selected) color stop: Alt-click swatch in the
Swatches panel" for the Gradient panel.

Now, when the active Fill or Stroke is a linear or radial gradient with
a selected stop, an Alt-click on a color swatch or a tint swatch
recolors that stop in one undo step, and the stop stays selected. The
stop changes as a drop of the same swatch on the Gradient panel's ramp
changes it (drop_color and dropped_link in panels/gradient.rs): it
becomes fully opaque, and a global or spot color or a tint stays
linked, so editing the swatch recolors the stop.
With a freeform gradient, the selected point takes the color through
paint.freeform.setPoint. With no stop or point selected, Alt-click
paints the inactive Fill or Stroke as before. The Fill and Stroke chip
popovers show the same swatches and behave the same.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
bflatastic 6902e1ab3f Dialogs: drop the slider wrappers nothing calls since the named ones 2026-10-17 13:49:49 +07:00
dotCooCoo 3fc78187ec TIFF: planar grayscale files with extra channels, and planar CMYK files, open and place
TIFFs that store each channel as its own plane (PlanarConfiguration 2)
did not open, place or relink when they were CMYK or grayscale with
extra channels: the image decoder refuses planar CMYK, and the readers
for CMYK inks and for extra samples returned nothing for planar files.

The two readers now read every plane through the tiff crate and put
each pixel's samples together. A planar CMYK TIFF keeps its ink
amounts and is stored as a CMYK TIFF with each pixel's inks together,
at the file's resolution. Grayscale TIFFs with an alpha channel or
other extra channels, and CMYK TIFFs with alpha, read as their chunky
versions do.

A TIFF whose header claims more strips or tiles than the tiff decoder
can count is refused as too large to draw. The decoder multiplies
those counts without a check, which panics in builds with overflow
checks.

Part of #1466.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo f0bb2760c9 Accessibility: buttons, toggles, checkboxes and sliders have names for screen readers
The controls that the shared widgets draw had no name in the
accessibility tree: icon, flat, primary, secondary and split buttons,
chips, toggles, checkboxes, radio buttons, tabs, sliders, the angle
dial, the Reference Point's squares, the spinner and preset arrows and
the searchable combo boxes. Screen readers announced each clickable one
as an unnamed element of unknown role, and a disabled icon button was
not in the tree. The Color Guide's library button, the Color Themes
Save button and the status bar's artboard buttons also had no name.

Each helper in widgets.rs except color_field now gives its response a
role, a name and its state. The name is the text, label or translated
tool tip the helper shows. The spinner, the preset arrow, the angle
dial, the harmony wheel and the Reference Point's squares, which show
none, are named "Step up or down", "Presets", "Angle", "Color wheel"
and Top Left to Bottom Right. A toggle, checkbox, radio button, tab or
Reference Point square reports whether it is on, an icon button reports
that it is pressed while it is lit, the angle dial reports its angle,
and a searchable combo box reports the text it shows. A checkbox or
radio button drawn without text is named by the label beside it. The
Tools panel's tool buttons, flyout rows and floating strips are named
by their tools' tool tips, with the active tool pressed. The title
bar's column toggle, the tear-off bars, a floating strip's close box
and the Color, Gradient and None buttons are named by their tool tips.

The status bar's First, Previous, Next and Last Artboard buttons now
have tool tips, and the Fill and Stroke chips' tool tips are
translated. The six new strings (the four artboard tips, "Color wheel"
and "Step up or down") have rows in every catalog.

split_button, chip_button, color_slider, channel_slider and the dialog
form's slider_field and slider_rail keep their arguments. New _named
variants beside them take the arrow's tool tip, the chip's tool tip or
the slider's name, and every caller in the tree uses them.

Docs: docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
bflatastic a71f8eb56a Persistence: startup reads at most 256 MB of stored colour profiles (#2174) 2026-10-17 13:49:48 +07:00
bflatastic 51f8582c25 MCP: a cancelled export keeps no partly drawn tile or raster, and its message is translated (#2165) 2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 6920adf092 PDF export: report RGB colours and images in a CMYK document (#2162)
A CMYK document holding RGB colours or images separates them into CMYK on export, and dark ones come out as rich blacks. The export report now says how many there are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 1eb9bea82a Inspect: scope document.inspect / doc_inspect by ids, kind, limit and offset (#2167)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
bflatastic d55b4f77cf AI import: cite the published format specification by its title 2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 a8e45f0e2d Persistence: saved PDF presets and loaded ICC profiles survive a restart (#2168, #2174)
PDF presets are kept one file each in a PDF Presets folder, ICC profiles as copies in a Color Profiles folder, next to the preferences; the desktop app and vectorcraft-cli use them. Adds color.removeProfile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 9122ed0a7e MCP: cancel long exports, answer while one runs, report progress (#2165, #2171, #2172)
The MCP server read one line, ran it and answered it before reading the next,
so notifications/cancelled was never seen during a call and every request
(even ping) waited behind a running export.

- vectorcraft_doc::cancel: a thread-local cancel token and progress sink that
  long work polls. Checkpoints: PDF pages, PDF flattening, geometry bake,
  raster-effect flattening, drawing objects, raster artboards, and before any
  file is written. A cancelled command returns EngineError::Cancelled and
  changes nothing.
- Server::serve reads on its own thread: notifications/cancelled drops a
  queued request or stops the running one (no reply, as the MCP spec says),
  engine-free requests (ping, the list methods, prompts/get) are answered at
  once, a request that would wait behind a call running for 2 s is refused
  with -32003 naming that call, and _meta.progressToken gets
  notifications/progress (page n of m). Queue and output are bounded.
- The remote backend stops waiting for the app when the call is cancelled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
Roman Vasilyev 8b8c164e3f CI: simplify boolean guards and translate EPS mask errors 2026-10-17 13:48:06 +07:00
Roman Vasilyev 4ed644d98d EPS: preserve editable Illustrator art and filter raster page comparison 2026-10-17 13:48:05 +07:00
BFlat 26644850fc Merge pull request #2484 from vangel76/fix/2332-corner-radius-to-neighbour
Live Corners: a corner rounds until its cut reaches its neighbours (#2332)
2026-10-17 13:45:25 +07:00
BFlat 8735dbc6fd Merge pull request #2237 from EuanTop/fix/2208-native-font-name-fallback
Fix native font labels when only legacy names are localized
2026-10-17 13:45:10 +07:00
BFlat c266083acd Merge pull request #2510 from vangel76/fix/2426-innerglow-pattern
Render: Inner Glow on a pattern fill draws along the edges, not at the top left (#2426)
2026-10-17 13:44:51 +07:00
BFlat a3dc3ca68e Merge pull request #2482 from vangel76/fix/2432-dimension-shift-preview
Dimension tool: Shift preview, snap indicator, clicks snap onto lines and anchors (#2432, #2516)
2026-10-17 13:44:37 +07:00
BFlat d82eafce7a Merge pull request #2487 from vangel76/fix/2457-tab-line-width
Type: a tab takes its width when area type breaks and justifies its lines (#2457)
2026-10-17 13:44:23 +07:00
BFlat f3ff11ff45 Merge pull request #2478 from vangel76/fix/2413-text-create-bounds
Type: text.create and text.createInPath return the new type's bounds (#2413)
2026-10-17 13:44:11 +07:00
BFlat cb33850e9c Merge pull request #2475 from vangel76/fix/2343-zigzag-smooth-wave
Effects: Zig Zag Smooth points draw a continuous wave (#2343)
2026-10-17 13:43:59 +07:00
BFlat a1372d498a Merge pull request #2486 from vangel76/fix/2451-transform-gradient
Effects: a Transform effect on a group paints each copy with its own gradient (#2451)
2026-10-17 13:43:48 +07:00
BFlat b9cc13beff Merge pull request #2477 from vangel76/fix/2476-effects-craft-fonts-skip
Effects: skip type tests when built without craft-fonts (#2476)
2026-10-17 13:43:37 +07:00
BFlat b5cadb9412 Merge pull request #2640 from storytold/pr/2585-quick-trace-docs
docs: quick Image Trace presets trace at once; the Image Trace button opens the controls (#2585)
2026-10-17 13:43:17 +07:00
BFlat 981f28469e Merge pull request #2566 from dotCooCoo/fix/artboard-commands-bad-index
Artboards: commands given an index that names no artboard fail instead of editing another one
2026-10-17 13:42:03 +07:00
BFlat 0072594442 Merge pull request #2568 from Silvertree2010/fix/h2255-graph-data-csv-does-not-round-tr
Quote graph data CSV labels so they round-trip through graph.setData
2026-10-17 13:41:52 +07:00
BFlat d27a356007 Merge pull request #2579 from dotCooCoo/fix/raster-effects-ppi-on-load
Document Raster Effects Settings: a file's resolution outside 1 to 2400 ppi opens in range
2026-10-17 13:41:40 +07:00
BFlat 02a0ad2541 Merge pull request #2599 from dotCooCoo/fix/appearance-inline-text-typing
Appearance: the inline effect editor's text fields keep what is typed
2026-10-17 13:41:29 +07:00
BFlat 71eae2559d Merge pull request #2601 from dotCooCoo/feat/space-shift-rotate-view
Rotate View: a Space+Shift drag rotates the view from any tool
2026-10-17 13:41:18 +07:00
BFlat c61fd7085f Merge pull request #2583 from dotCooCoo/fix/presentation-mode-non-editable
View: in Presentation Mode no key but ←, → and Escape acts, and no context menu opens
2026-10-17 13:41:06 +07:00
BFlat e9c6c53b2a Merge pull request #2613 from dotCooCoo/feat/links-shift-range
Links: Shift-click selects a range of links
2026-10-17 13:40:56 +07:00
BFlat 8aef393aab Merge pull request #2612 from dotCooCoo/feat/graphic-styles-shift-range
Graphic Styles: Shift-click selects a range of styles
2026-10-17 13:40:41 +07:00
BFlat c94f93ddc0 Merge pull request #2618 from dotCooCoo/fix/library-panel-click-anchor
Libraries: a plain click sets where a Shift-click range starts
2026-10-17 13:40:26 +07:00
BFlat 8b8607db8c Merge pull request #2620 from dotCooCoo/fix/issue-2384
Part of #2384: imported PDF text keeps its place after wide gaps between words
2026-10-17 13:40:11 +07:00
BFlat c600673648 Merge pull request #2622 from odgrim/fix/intertwine-expand-2540
Fix #2540: Object → Expand expands Intertwine groups
2026-10-17 13:39:54 +07:00
BFlat 63fc56f327 Merge pull request #2511 from vangel76/feat/2412-export-rect
Export: raster document.export takes a page rectangle (#2412)
2026-10-17 13:39:39 +07:00
BFlat da90552ee5 Merge pull request #2495 from vangel76/fix/2494-gradient-chip-angle
Gradient chips: follow the angle and the gradient type (#2494)
2026-10-17 13:39:24 +07:00
BFlat d60f3b12ff Merge pull request #2499 from vangel76/fix/2421-floating-panel-opaque
Panels: floating panel groups paint an opaque background (#2421)
2026-10-17 13:39:11 +07:00
BFlat 92fa9cbab2 Merge pull request #2498 from vangel76/fix/2383-merge-selected-sublayer
Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383)
2026-10-17 13:38:47 +07:00
BFlat 8d185babb4 Merge pull request #2514 from vangel76/fix/2415-overprint-same-colour
Overprint Preview: a colour overprinting itself stays that colour (#2415)
2026-10-17 13:38:34 +07:00
BFlat 0e3ce7744b Merge pull request #2503 from vangel76/fix/2493-generic-cmyk-black-roundtrip
Color: Generic CMYK black round-trips through hex (#2493)
2026-10-17 13:38:22 +07:00
BFlat d5a3422dcd Merge pull request #2517 from vangel76/fix/2323-overflow-marker
Type: selected overflowing type shows its red out port with any tool (#2323)
2026-10-17 13:38:07 +07:00
BFlat 1d19d84493 Merge pull request #2518 from vangel76/fix/2217-kerning-caret
Character: the Kerning field at a caret kerns the pair, not the whole text (#2217)
2026-10-17 13:37:54 +07:00
BFlat 1686e6748e Merge pull request #2519 from vangel76/fix/2221-png-srgb
PNG export: an sRGB chunk marks the colours as sRGB (#2221)
2026-10-17 13:37:42 +07:00
BFlat d67290253d Merge pull request #2502 from vangel76/fix/2450-wayland-cursor-theme
Wayland: pointer returns over panels and menus after a tool cursor (#2450)
2026-10-17 13:37:17 +07:00
BFlat 4064ef8656 Merge pull request #2570 from dotCooCoo/fix/zoom-hint-test-language-race
Tests: a language one ui-egui test sets no longer translates the tests running beside it
2026-10-17 13:36:59 +07:00
BFlat ffd343f058 Merge pull request #2605 from dotCooCoo/fix/cli-test-stale-temp-folders
Tests: CLI tests do not fail on folders an earlier run left behind
2026-10-17 13:36:44 +07:00
BFlat 938fb5bce6 Merge pull request #2463 from dotCooCoo/fix/action-batch-wasm-unused-import
Lint: action_batch.rs compiles without an unused import on wasm32
2026-10-17 13:36:26 +07:00
BFlat f5241fabd0 Merge pull request #2447 from Silvertree2010/fix/h2170-xml-id-round-trip-changes-names
Escape XML ID first characters that cannot start an XML name
2026-10-17 13:36:05 +07:00
BFlat b81d8ccf6c Merge pull request #2470 from Silvertree2010/fix/h2224-color-from-hex-accepts-as-a-hex
Reject '+' and other non-hex characters in Color::from_hex
2026-10-17 13:35:54 +07:00
BFlat ce45d2a79c Merge pull request #2610 from Silvertree2010/fix/h2365-graph-axis-labels-saturate-at-92
Fix graph axis labels saturating beyond the i64 range
2026-10-17 13:35:44 +07:00
BFlat 2469a77ad4 Merge pull request #2440 from dotCooCoo/fix/raster-effect-export-huge-bounds
PDF export: Add Around is clamped when a file opens, and art too large for f64 gets no effect image
2026-10-17 13:35:34 +07:00
BFlat fd28438c57 Merge pull request #2407 from dotCooCoo/fix/crop-image-trim-size-check
Crop Image: trim checks the image's RGBA size before decoding it
2026-10-17 13:35:19 +07:00
BFlat 27b5eb7100 Merge pull request #2468 from dotCooCoo/fix/rgba-within-size-overflow
Images: sampling and recoloring check an image's RGBA size without overflowing
2026-10-17 13:35:02 +07:00
BFlat 38314db4ba Merge pull request #2604 from dotCooCoo/fix/decode-failure-count-race
Render: an export no longer warns about images another thread could not decode
2026-10-17 13:34:44 +07:00
BFlat 1aaa81f56b Merge pull request #2442 from dotCooCoo/fix/render-image-rgba-size-check
Render: check an image's RGBA size before decoding it
2026-10-17 13:34:34 +07:00
bflatastic d5561d6e02 Live Corners: drop the single-corner radius limits nothing uses since #2332 2026-10-17 13:20:26 +07:00
EuanTop 0746519517 Fix localized legacy font family name fallback 2026-10-17 13:20:26 +07:00
bflatastic 8fead43ed2 Tests: only the new Inner Glow test lays a background under the pattern (#2426) 2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 4dcd0b6caa Dimension tool: show where a Linear click snaps before the click (#2516)
The snap ring on an anchor (or a dot on a line) and the rubber band now follow the snapped point, so both measured points visibly land on anchors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 285a3da81a Type: centre and right aligned lines set their tabs as if they started at the left (#2457)
A tab's width depends on where it falls from the tab origin. Line breaking and the line width measure it from the left edge, but a centred or right aligned line is placed from its own start, so its tabs stopped elsewhere. The line is now laid out with its tabs measured from the left and then aligned as a whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Sonnet 5.5 4f30e52368 Type: text.create and text.createInPath return the new type's bounds (#2413)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 d3751cc07b Effects: Zig Zag Smooth points draw a continuous wave (#2343)
Smooth Zig Zag ran the ridge points through a Catmull-Rom spline. Its end tangents slant across the path, and where the ridges are taller than about twice their spacing the sharp-turn floor of turn_tangent lengthens the handles, so the result read as straight diagonals with rounded tips. Each peak and valley is now a smooth point whose handles run along the path, a third of the way to the neighbouring ridge, so every span follows a half cosine through the path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 7cd6b5d983 Effects: Expand Appearance and export give each Transform copy of a gradient path its own gradient (#2490)
A path with a Transform effect that makes copies and a gradient was still merged into one path by bake_leaf and expand_leaf, so the gradient spanned every copy. Route it through reshape / expand_outlined like the renderer does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 8875b3fbca Live Corners: a corner rounds until its cut reaches its neighbours (#2332)
A corner's radius was capped at half its shorter side whatever its neighbours did, so one corner of a 100 pt square rounded alone stopped at 50 pt. Each corner now rounds until its cut reaches the cuts its neighbours ask for: the whole side when the neighbour is square, halfway when both ask for more. The corner widget drag, the red limit outline and CSS border-radius export follow the same limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 2108c3cc99 Render: a glow's pixmap is drawn without the paint transform a pattern fill left (#2426)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 848e7a296a Dimension tool: a click near a line snaps the measured point onto the line (#2432)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 101fd55fa0 Type: a tab takes its width when area type breaks and justifies its lines (#2457)
Line breaking, the line's natural width (and so justification) and the composer counted a tab as its glyph's width, a space, while it is set up to its next tab stop. A paragraph starting with a tab, as in the report, filled a justified line as if the tab were one space, and the line ran past the frame. break_line and the line width now measure tabs with tab_advance, and a paragraph with a tab is broken line by line, since the composer works with fixed glyph widths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 6939a481a4 Dimension tool: Shift shows the constrained line while picking the second point (#2432)
The Shift click already snapped the second point to 45-degree steps, but the dashed line from the first point followed the raw pointer, so the constraint looked absent until the click.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 b28e8490c2 Effects: skip type tests when built without craft-fonts (#2476)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:25 +07:00
andeecollardandClaude Opus 5.5 b7018a3370 docs: quick Image Trace presets trace at once; the Image Trace button opens the controls
The UI parity row (and G12 in gaps.md, M12 in roadmap.md) said quick Image
Trace presets reveal the editable controls. Since baadbe2c6 a quick trace
leaves the panel as it was (test a_quick_trace_leaves_the_panel_as_it_was)
and the Image Trace button in the Control bar or Properties opens the
controls. That matches the public documentation: choosing a preset traces
the image with it straight away, and the panel is opened with the Image
Trace button in the Control panel or the Properties panel's Image Trace
section
(https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html).

Reworded the three rows, cited the URL, added revision-history entries and
status-line notes. Wording only; scores, statuses and hours unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 13:20:25 +07:00
vangel76andClaude Opus 5.5 a6012d86e8 Effects: a path with its own Transform effect paints each copy with its own gradient (#2451)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:25 +07:00
vangel76andClaude Opus 5.5 27230233cc Effects: a Transform effect on a group paints each copy with its own gradient (#2451)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:25 +07:00
dotCooCoo ee61583606 Artboards: commands given an index that names no artboard fail instead of editing another one
artboard.setProps and artboard.fitToSelection read an `index` that is
negative, fractional, a string or null as 0 and edited the first
artboard, and artboard.setProps edited the first artboard when the
call named no `index`. artboard.duplicate, artboard.copy and
artboard.cut took the Artboard tool's artboard for such an `index`.
artboard.reorder failed for it with "missing index".

These commands now read `index` as artboard.delete, artboard.move and
artboard.fitToArt do, and fail with "no such artboard" when it names
no artboard; the document and the clipboard stay as they were.
artboard.setProps without `index` fails with "missing index". Without
`index`, artboard.fitToSelection still fits the first artboard and the
copy commands still take the Artboard tool's artboard.

Docs: docs/mcp.md.
2026-10-17 13:20:23 +07:00
NetterSilverandClaude Haiku 5.5 92647fa5b6 Quote graph data CSV labels so they round-trip through graph.setData
Found while testing crates/doc/src/graph.rs:429 (GraphSpec::to_csv) and crates/engine/src/cmd/graph.rs:85 (parse_csv)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
dotCooCoo 1e3886c053 Document Raster Effects Settings: a file's resolution outside 1 to 2400 ppi opens in range
document.rasterEffectsSettings accepts a resolution from 1 to 2,400
ppi, but a file's raster_effects_ppi opened as it was saved. With 0,
-5 or 5000 in the file, object.rasterize without a ppi param failed
with "ppi must be between 1 and 2400", and document.rasterEffectsSettings
and document.info reported the stored value. With 0 or -5, EPS and
PDF export wrote a freeform gradient as a 1 x 1 px image, and EMF
export wrote every gradient as one. A saved New Document preset in the
preferences kept such a value too.

A file's or a saved preset's resolution outside 1 to 2,400 ppi is now
read as the nearer end of that range. Documents with a resolution in
range open as before.

Docs: docs/mcp.md (resolution, rasterEffectsPpi).
2026-10-17 13:20:23 +07:00
dotCooCoo 5eea7e4f66 Appearance: the inline effect editor's text fields keep what is typed
The Appearance panel's inline effect editor drew each text parameter
(such as Drop Shadow's color and Revolve's edge) as a text field over
a copy of the stored value made again in every frame. A character
typed in one frame was gone in the next, so the field changed a value
only when its new text and the Enter press arrived in the same frame,
as with a paste.

The field is now the panels' shared exact_text_field, which keeps the
text typed while it has the focus, as the number fields beside it do.
Enter or a click elsewhere sends the value with effect.setParams as
one undo step, and Escape drops it.
2026-10-17 13:20:23 +07:00
dotCooCoo 2e9870b1d0 Rotate View: a Space+Shift drag rotates the view from any tool
A drag with Space and Shift held panned the view, as a Space drag does.

With Space and Shift held and Cmd up, a drag on the canvas now turns
the view about the canvas center, as a Rotate View tool drag does,
whatever the tool. Shift does not round the angle to 15° as it does
with the Rotate View tool, and the drag keeps turning the view when
Shift is let go. Space alone still pans, and Cmd+Space still zooms
with or without Shift. The pointer shows the rotate cursor while Space
and Shift are held without Cmd, and during the drag. The Rotate View
tool also shows it now, where it showed the arrow.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 951f7c2842 View: in Presentation Mode no key but ←, → and Escape acts, and no context menu opens
In Presentation Mode the canvas showed the active tool's cursor, a
right click selected the object under the pointer and opened the
context menu, and every key other than the arrows and Escape worked as
in Normal Screen Mode: tool keys, Delete, Enter, Tab, F and the
command shortcuts. A two-finger tap undid. With a tool that claims
Escape, such as the Intertwine tool, Escape did not leave the mode.

In the mode only the Escape, Left Arrow and Right Arrow keys now act,
and Escape leaves the mode whatever the tool. Every other key press,
the text it types and the clipboard keys are dropped before any
shortcut reads them, the macOS menu bar's key equivalents included. A
right click or a long touch opens no context menu, a two- or
three-finger tap undoes or redoes nothing, and the pointer over the
canvas is the default one. F and Shift+F no longer leave the mode;
Escape does.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 34ae88292d Links: Shift-click selects a range of links
In the Links panel, Shift-click and Cmd/Ctrl-click both added or
removed the image of the one row clicked. Shift-click now selects the
images of every row from the last row clicked without Shift to the
clicked one, in the order the panel shows them (its Show filter and
Sort order), and Cmd/Ctrl-Shift-click adds them to the selection.
Cmd/Ctrl-click still adds or removes one image, a plain click selects
one, and a double-click still goes to the link. When the last row
clicked is not shown, or the last click was in another document,
Shift-click acts as a click without Shift.

A click on a row runs select.set, select.add or select.toggle, and the
range is worked out as in the Layers panel's Shift-click.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo a8fbae1b1f Graphic Styles: Shift-click selects a range of styles
In the Graphic Styles panel, Shift-click and Cmd/Ctrl-click both added
or removed the one style clicked. Shift-click now adds to the selection
every style from the last one clicked without Shift through the
clicked one, in the order the panel lists them, and Cmd/Ctrl-click
still adds or removes one. A plain click applies the style and selects
it alone, Alt-click still adds it on top, and the Control bar's Style
picker still applies on every click.

The panel uses swatches::click_selection, as the Swatches panel and
the library panel do.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 3c5d0e7ce4 Libraries: a plain click sets where a Shift-click range starts
A plain click on an item in the library panel selected the item but did
not record it as the anchor that a Shift-click ranges from. A later
Shift-click then selected only the clicked item, or ranged from an
older anchor set by an earlier Cmd/Ctrl-click or folder click. This
applied to swatch libraries and graphic style libraries.

Every click on an item or folder now goes through click_selection, as
in the Swatches panel. A plain click still selects the item alone and
adds and applies it.
2026-10-17 13:20:23 +07:00
dotCooCoo fbcb217789 Part of #2384: imported PDF text keeps its place after wide gaps between words
PDF import reads a gap wider than a fifth of an em between two glyphs
as a space, and a space that the line's tracking cannot place gets
tracking of its own. That tracking was clamped to 1000 thousandths of
an em, while type takes tracking from -1000 to 10000. A gap wider than
an em plus the width of a space came in short by the excess, so the
words after it sat to the left of where the file put them, and each
further wide gap added to the shift.

The tracking that import sets for such a space, and for the line around
it, now has the same range as type's.
2026-10-17 13:20:23 +07:00
Brian TorresandClaude Opus 5.5 7c80b286c7 Fix #2540: Object → Expand expands Intertwine groups
Expand had nothing for Intertwine groups. It reported success but left
the group live (it only dropped the members' live shapes), and its
Stroke and Fill options could not reach the members, so a second Expand
found nothing to do. Ungroup takes apart plain groups only, so it did
nothing either.

Expand's Object option now turns every Intertwine group in the selection
(inside groups too, inner ones first) into a plain group of what it
draws: its objects, keeping their ids and order, then each part drawn
over the others as a clip group. The group keeps its id, place,
transparency and appearance, everything happens in one undo step, and
the dialog enables Object for an Intertwine group. Ungroup then takes
the result apart, as with an expanded blend or repeat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
vangel76andClaude Opus 5.5 91fa3af21d Export: raster document.export takes a page rectangle (rect) (#2412)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
vangel76andClaude Opus 5.5 577239d734 Gradient chips: follow the angle and the gradient type (#2494)
gradient_chip now takes the GradientPaint and lays the gradient out as it would be on an object of the chip's shape: linear along its angle, radial from the centre, freeform from its colour points. Gradient panel swatch menu and the Swatch Options dialog carry the full paint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:22 +07:00
bflatastic 39979d4753 Tests: the floating-panel test skips fills that paint nothing (#2421) 2026-10-17 13:20:22 +07:00
bflatastic 424a49604c Layers: Merge Selected keeps to the highlighted layers, as documented (#2383)
Reverts the fallback that merged the layers of art selected on the canvas when no
layer row is highlighted: Illustrator's Merge Selected merges the highlighted rows
into the last one highlighted.
2026-10-17 13:06:52 +07:00
bflatastic 47b6b3285d Panels: floating groups get an opaque fill beneath them; the docked card keeps its rounded corners (#2421) 2026-10-17 13:06:52 +07:00
vangel76andClaude Opus 5.5 ecbbba4f2e Panels: floating panel groups paint an opaque background (#2421)
The shared dock left its pane and tab strip backgrounds transparent because the docked card behind them is filled. Floating groups lie on the canvas, so the canvas showed through. They now paint the panel colour, which is what the card shows when docked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:52 +07:00
bflatastic 2a3efa02f2 Overprint Preview: overprinting characters draw with Darken too (#2415) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 8ac4c9d268 Color: Generic CMYK compensates black both ways so black round-trips (#2493)
sRGB to Generic CMYK applies black-point compensation (black separates to the
darkest rich black) but CMYK to display did not undo it, so hex 000000 became
C67 M67 Y67 K100 and then showed as #1b1818. The display XYZ now maps the press
black to black when the colour settings use black-point compensation, in the
sRGB conversion, the ink lookup table the canvas uses, Lab of a CMYK colour and
the gamut warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 d39e02b3fe Type: selected overflowing type shows its red out port with any tool (#2323)
The overflow marker was drawn by the Type tools only, so with the Selection tool a text box smaller than its text showed no sign that text was hidden. Session::overlays now adds the marker of every selected area type and type on a path that overflows, unless a Type tool is active (it draws its own). The verdict is cached per object and font generation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 5fd7fd4312 Character: the Kerning field at a caret kerns the pair, not the whole text (#2217)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 57c79dc0b4 PNG export: an sRGB chunk marks the colours as sRGB, embedIcc turns it off (#2221)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
bflatastic 1fbd35560d Tests: the cursor-theme test compares paths, so it passes on Windows (#2450) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 fdc08e25d6 Layers: Merge Selected merges the layers of art selected on the canvas (#2383)
With no highlighted rows and art selected in two or more layers (Select All), layer.merge merges those layers; the panel menu enables it then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 7e423f91e9 Overprint Preview: overprinting draws with Darken, so a colour over itself stays that colour (#2415)
Multiply squared a colour overprinting itself (stroke over same-colour fill showed a darker band). Darken keeps the stronger ink per plate/channel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 1ff7ac7557 Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
dotCooCoo 476d228262 Tests: a language one ui-egui test sets no longer translates the tests running beside it
The language the UI is drawn in was one process-wide value in unit
tests too, and the tests run on parallel threads.
current_language_is_a_frame_setting sets Traditional Chinese for a
moment, and a test that drew text in that moment read it in Chinese.
chrome::tests::the_zoom_hint_follows_animated_zoom failed that way in
29 of 500 runs together with the i18n tests.

The drawing language is now one per process in the app and one per
thread in unit tests, as ui_mirror! keeps the shortcut and workspace
mirrors. The app keeps the same static. A new test sets the language
on another thread and checks that its own thread still draws in
English.
2026-10-17 12:50:38 +07:00
dotCooCoo 6961bed99d Tests: CLI tests do not fail on folders an earlier run left behind
The tmp() helpers in apps/vectorcraft-cli/tests/cli.rs and fileio.rs
name their folder after the process id (vectorcraft-cli-test-<pid>,
vectorcraft-cli-fileio-<pid> in the temp folder) and never empty or
remove it. When a later run gets the same process id, it finds the
earlier run's files there. mcp_automation_roots then fails on Unix with
EEXIST (Os { code: 17, kind: AlreadyExists, message: "File exists" }):
its first symlink() call finds the link in/escape.svg that the earlier
run made. A stale output file can also let a fileio test read an
earlier run's export.

The first tmp() call in each test process now removes the folder and
creates it again empty. The roots test's body moves into
automation_roots(), and mcp_automation_roots_in_a_folder_an_earlier_run_left
runs it twice in one folder, emptying the folder in between as tmp()
does. Without the removal the second run fails with the same error.
2026-10-17 12:50:38 +07:00
vangel76andClaude Opus 5.5 37dfdd5da5 Cursor: keep the system pointer where no cursor theme loads on Wayland (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
vangel76andClaude Opus 5.5 743930ac11 Wayland: give winit the desktop cursor theme so the pointer returns over panels (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
dotCooCoo efdb04db8a Lint: action_batch.rs compiles without an unused import on wasm32
On main, cargo xtask wasm printed an unused-import warning for json in
crates/engine/src/cmd/fileio/action_batch.rs, and this check reported
it as an error:

    RUSTFLAGS=-Dwarnings cargo check -p vectorcraft-engine --target wasm32-unknown-unknown

Every use of json! in action_batch.rs is in code compiled only for
native targets. The wasm32 version of action_batch uses Value only.

The json import now has the same #[cfg(not(target_arch = "wasm32"))]
attribute as the code that uses it. Native builds import the same
names as before.
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 80685831ac Escape XML ID first characters that cannot start an XML name
Found while testing crates/doc/src/xml_id.rs:31 (from_object_name) / :10 (to_object_name)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 3688ad7a46 Reject '+' and other non-hex characters in Color::from_hex
Found while testing crates/color/src/lib.rs:184

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 8ca4cd2cbf Fix graph axis labels saturating beyond the i64 range
Found while testing crates/engine/src/cmd/graph.rs:193 (fmt_value)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
dotCooCoo 3d2e8bbe82 PDF export: Add Around is clamped when a file opens, and art too large for f64 gets no effect image
document.rasterEffectsSettings accepts addAround from 0 to 1,000 pt,
but a file's raster_effects.addAround opened as it was saved. With
addAround 1e300, the area of a raster effect image overflowed f64,
the image scale became 0 and the image got the transform
[inf, NaN, NaN, inf, x, y]. PDF export left that image out, so the
drop shadow was missing.

A file's addAround now opens clamped to 0 to 1,000 pt. An effect
image whose area or scale is not finite is not made. Export then
keeps the object with its effects, and Expand Appearance expands it
without an image, as when its PNG can't be encoded.

Docs: docs/mcp.md (addAround).
2026-10-17 12:50:36 +07:00
bflatastic 0af1741e24 Crop Image: the RGBA size check can't overflow on a huge header 2026-10-17 12:50:36 +07:00
dotCooCoo db2bbcf405 Images: sampling and recoloring check an image's RGBA size without overflowing
Before the Eyedropper samples an image, or Edit Colors, Recolor Artwork
or a color adjustment effect recolors an embedded one, `rgba_within` reads
the image's size from its header and checks its size as RGBA against the
allowance. The check multiplied width × height × 4 in 64 bits. A TIFF's
sides go up to 2^32 - 1 pixels, so a header of 2^31 × 2^31 pixels
overflowed that product: debug and test builds panicked with "attempt to
multiply with overflow", and release builds wrapped it to 0 before the
decoder's own 512 MB limit refused the image.

The check now compares width × height with the allowance divided by 4.
Every size whose RGBA bytes fit in 64 bits gets the same answer as
before, and a larger one is refused.
2026-10-17 12:48:19 +07:00
dotCooCoo 9bee299dd5 Render: an export no longer warns about images another thread could not decode
A raster export compared the process-wide count of images that could
not be decoded before and after it drew, and warned that "an image
couldn't be decoded and is missing from the picture" when the count
rose. Any other thread that failed to decode an image in that time
raised the count too: a second export, the UI thread drawing the
canvas, or the canvas, thumbnail and placed-document workers drawing a
document with such an image. The export then warned about an image it
does not contain. In the engine's tests,
options_not_applied_yet_and_document_features_warn failed in 5 of 60
runs of the tests_raster and tests_pdf* tests.

The count is now kept per thread. An export reads it on the thread it
draws on. That thread decodes every image the export draws that is not
already in the shared image store, and an image that failed to decode
is never stored.
2026-10-17 12:48:19 +07:00
dotCooCoo 0f987812b0 Render: check an image's RGBA size before decoding it
The renderer decoded each image within its 2 GB allowance (512 MB in
the web build) and then converted it to RGBA. The decoder holds the
decoded image, before conversion, to that allowance, so an image stored
with fewer than four bytes a pixel (gray, 1-bit, gray with alpha, or
RGB without alpha) could take up to four times as much (4/3 times for
RGB) once converted, and as much again as the pixmap drawn from it.

The renderer now reads the image's size from its header first. When
width × height × 4 is over the allowance it does not decode the image
and handles it as one it can't decode: the renderer draws nothing for
it, and a raster export warns that an image is missing. Images within
the allowance are decoded and drawn as before.
2026-10-17 12:48:19 +07:00
dotCooCoo 6b0d1ed1c0 Crop Image: trim checks the image's RGBA size before decoding it
Crop Image's trim (object.cropImage {trim: true}) decoded the image
within MAX_RASTER_ALLOC (2 GB) and then converted it to RGBA. The
decoder holds the decoded image, before conversion, to that allowance,
so a gray or 1-bit image within it could take up to four times as much
once converted.

Trim now reads the image's size from its header first and, when
width × height × 4 is over the allowance, fails before anything is
decoded, with the check and message Load Texture uses: "an image of
W × H pixels is too large to draw". Images within the allowance are
trimmed as before.
2026-10-17 12:48:19 +07:00
BFlat 49bdc5cdd4 Merge pull request #1510 from marcoarnulfo/fix/ai-layers-collapsed
Layers: a .ai another app wrote opens with its top-level layers closed, as its groups
2026-10-17 12:46:37 +07:00
BFlat 7a8387f467 Merge pull request #1365 from Messhias/feat/394-highlight-alternate-glyphs
Part of #394: Highlight Alternate Glyphs paints pink behind OpenType substitutions
2026-10-17 12:46:18 +07:00
BFlat 12758df31f Merge pull request #2334 from bobtherobot/feat/tool-cursors
Cursors: 36 more tool states get cursors of their own, listed in cursors.toml
2026-10-17 12:46:03 +07:00
BFlat a76faaa21f Merge pull request #2393 from pechnikov2026-star/i18n-nl-da-hi-id
i18n: add Dutch, Danish, Hindi and Indonesian interfaces
2026-10-17 12:45:46 +07:00
BFlat 21a9da7360 Merge pull request #2379 from dotCooCoo/fix/macos-save-format-menu
Fix #866: macOS save sheets have a Format menu again
2026-10-17 12:45:28 +07:00
Marco Arnulfo f31d91bf1a Layers: a .ai another app wrote opens with its top-level layers closed, as its groups (fixes #1475) 2026-10-17 12:26:14 +07:00
bflatastic 52771905e6 Part of #394: rustfmt the alternate-glyph test 2026-10-17 12:26:14 +07:00
Fabio William Conceição 0ffe0c7c0e Part of #394: Highlight Alternate Glyphs defaults to off. 2026-10-17 12:26:13 +07:00
Fabio William Conceição 8689863fbf Part of #394: Highlight Alternate Glyphs paints pink behind OpenType substitutions. 2026-10-17 12:26:13 +07:00
Michael GiesonandClaude Opus 5.5 2d0da1472f Cursors: 36 more tool states get cursors of their own, listed in cursors.toml
Node editing as the public manual describes it: Direct Selection's anchor squares (hollow
or filled), Reshape Segment over a curved segment, Group Selection's plus (also Direct
Selection with Alt), the arrow hollow while a drag snaps, and Alt-copy arrowheads. The
Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows (ShearH,
ShearV) are drawn in code. Lasso, the freehand, symbolism and other tools return cursors
of their own that borrow their old look until drawn (Cursor::stand_in).

crates/tools/src/cursors.toml lists every Cursor value, when it shows and its hotspot,
for whoever draws them; `cargo xtask cursors` (in ci) warns about a missing, unknown,
repeated or incomplete entry and never fails. The busy cursor is listed as missing (G1).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 12:26:12 +07:00
sanches 816097c6bf i18n: add Dutch, Danish and Indonesian interfaces
Complete catalogs for nl, da and id, registered with their locales. Hindi from the same PR waits for a Devanagari UI font decision (with Lao and Georgian), and the strings main gained since the catalogs were written are added.
2026-10-17 12:26:11 +07:00
dotCooCoo 1d7ad173aa Fix #866: macOS save sheets have a Format menu again
On macOS, the save sheets of Save As, Save a Copy and the first Save of
a new document have a Format menu again, and the Save as Template sheet
now has one too. The panel is an NSSavePanel built in
apps/vectorcraft/src/mac_save_format.rs, and the menu is its accessory
view, set before beginSheetModalForWindow:completionHandler: shows the
sheet. The menu from #1235 was added after rfd had begun the sheet, and
recent macOS versions dismissed the sheet at once (#1800, #1831,
#1518); #1855 removed it.

Choosing a format changes the extension in the name field and puts
that format's extension first in the allowed file types, which AppKit
adds to a name typed without one. A name typed with another listed
extension is saved in that format. Save panels with one file type and
the open and folder panels are still shown by rfd.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
2026-10-17 12:26:10 +07:00
BFlat 8bc6532928 Merge pull request #2399 from storytold/pr/release-0.12.0
Release: VectorCraft v0.12.0
2026-10-16 18:28:18 +07:00
bflatastic 4791c88809 Release: VectorCraft v0.12.0
What's new for users since v0.11.0:

- A restyled interface: Medium Dark with a coral accent by default, a choice
  of accent colours, and a Classic colour scheme (User Interface › Color
  Scheme) with the neutral greys of earlier versions.
- New tools and commands: Object › Intertwine and the Intertwine tool, the
  Dimension tool, Export for Screens name patterns, folders and presets,
  File › Automate › Batch, Sample Color from Screen, Release Guides into
  paths, Update Selection, Rotate View to Selection and Rotate View presets,
  Outline Object, the Halftone Pattern effect, canvas scroll bars, the
  Hyphenation dialog, Legacy .ai export and portable mode on Windows.
- Canvas editing: the ellipse pie widget, the Shaper Group arrow, E for an
  enclosed marquee, Unlock from a right-click on locked objects, Blob Brush
  Keep Selected and Merge Only with Selection, Simplify's angle threshold and
  straight lines, Distribute with a spacing from a key object, and Ctrl/Cmd+
  Enter for proportional W and H.
- Type: font size steps of five, reset horizontal scale, reset tracking and
  kerning, Align Stroke on live type, and Find and Replace that keeps
  paragraph styles.
- Variables: image and graph variables, data set CSV import and export.
- Import: many .ai, EPS and PDF fidelity fixes (images, gradients, meshes,
  patterns, layers, File Info), damaged PDFs no longer crash the importer,
  and .ai, .ait and .svgz files copied in a file manager paste as art.
- Image Trace keeps tiny parts, thin outlines, shared corners and junctions,
  and Noise no longer splits merged regions.
- MCP: draw_path and draw_shape as batch steps with dashes, caps and effects,
  and command listings filtered by query and menu.
- Languages: Swedish, Finnish, Greek and Bulgarian, and Brazilian Portuguese
  completed.
- Windows: a graphics driver crash leaves that adapter out at the next start,
  and a maximized window stays clear of the taskbar.
2026-10-16 18:28:02 +07:00
BFlat 06b33b0d2d Merge pull request #2385 from dotCooCoo/fix/hidden-widgets-ignore-clicks
Hide Edges: the hidden bounding box, type widget and corner widgets do not answer the pointer
2026-10-16 16:13:37 +07:00
BFlat 4664907fdc Merge pull request #2386 from dotCooCoo/feat/release-ruler-guides
Guides: Release Guides turns selected ruler guides into paths
2026-10-16 16:13:22 +07:00
dotCooCoo 35b9d5a645 Hide Edges: the hidden bounding box, type widget and corner widgets do not answer the pointer
With View › Hide Edges on, the canvas does not draw the bounding box,
the type widget beside it or the Live Corners widgets, but the
selection tools still answered the pointer on them. A press on a hidden
corner widget rounded the corners, a drag from a hidden handle scaled
the selection, a double-click on the hidden type widget converted the
type, and the cursor changed over each of them.

While edges are hidden, the app now tells the tools that the bounding
box and the corner widgets are off. A press at their places reaches the
art or the canvas under them.

Docs: docs/target-app-parity.md.
2026-10-16 16:07:22 +07:00
dotCooCoo 18b472092e Guides: Release Guides turns selected ruler guides into paths
View › Guides › Release Guides only cleared the guide flag on paths
made with Make Guides. Ruler guides could not become paths by any
route, and with only ruler guides selected the command released every
guide path in the document instead.

Release Guides now also releases the selected ruler guides. Each one
becomes an open straight path with no fill or stroke at the top of the
guide's layer: an artboard guide runs across its artboard, and a
canvas guide runs across the bounds of all the artboards. The ruler
guide is deleted, the released paths are selected, and the release is
one undo step. With no guide selected, every guide path is released,
as before.

view.guides.release takes index (a ruler guide) and ids (guide paths,
and the guide paths inside them). Both are refused while guides are
locked, and index takes only a guide on a shown, unlocked layer.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 16:07:21 +07:00
BFlat 32a3362e7b Merge pull request #1319 from FredApps/fix/dxf-closed-spline-1299-20261012
Preserve explicit closed flags in DXF SPLINE export
2026-10-16 15:55:05 +07:00
BFlat 4bb469563b Merge pull request #1406 from marcoarnulfo/feat/portable-mode
Portable mode: a portable.txt beside the executable keeps the settings in VectorCraftData (#1366)
2026-10-16 15:54:48 +07:00
BFlat d59733937a Merge pull request #2348 from Gamedirection/feat/batch-export
Export for Screens: batch exporting with name patterns, folders and presets (#1935)
2026-10-16 15:54:32 +07:00
BFlat 1799fa533e Merge pull request #2310 from dotCooCoo/feat/hyphenation-options
Paragraph: the Hyphenation dialog sets each paragraph's hyphenation limits
2026-10-16 15:54:14 +07:00
BFlat b32017d44f Merge pull request #2197 from turtwig/artboard-edit-fixes
Artboard editing: fit on double-click, rename on the canvas, and highlight the active artboard
2026-10-16 15:54:00 +07:00
BFlat 0c9b65c931 Merge pull request #1368 from andeecollard/fix/trace-noise-regions
Fix Image Trace Noise splitting previously merged regions
2026-10-16 15:53:44 +07:00
BFlat 2d230dc31a Merge pull request #2205 from andeecollard/fix/trace-junction-returning-pieces
Image Trace: retain pieces returning to a junction
2026-10-16 15:53:29 +07:00
BFlat c06c794e5a Merge pull request #2297 from switch87/stroke-align-type-2294
Fix #2294: Align Stroke inside or outside on live type
2026-10-16 15:45:55 +07:00
BFlat 143e4d78e7 Merge pull request #2308 from dotCooCoo/feat/print-overflow-warning
Print: Print and Print Preview warn about type that doesn't fit its frame
2026-10-16 15:45:42 +07:00
BFlat cb3915d230 Merge pull request #2319 from dotCooCoo/feat/outline-object-effect
Effects: Effect › Path › Outline Object turns type into live, editable outlines
2026-10-16 15:45:27 +07:00
BFlat 93913c7906 Merge pull request #2320 from dotCooCoo/fix/art-bounds-take-in-effects
Export: the art's bounds take in the effects of groups, type and images
2026-10-16 15:45:12 +07:00
BFlat c459285f42 Merge pull request #2333 from dotCooCoo/feat/outline-object-type-item-effects
Type: geometry effects on a type object's own fills and strokes are drawn and exported
2026-10-16 15:44:58 +07:00
BFlat e31a39ec40 Merge pull request #2338 from dotCooCoo/feat/update-selection
Select: Update Selection replaces the recalled saved selection with the current selection
2026-10-16 15:44:42 +07:00
BFlat 36deb67f20 Merge pull request #2340 from marcoarnulfo/feat/scrollbars
Canvas: scroll bars along the window's right and bottom edges (#2322)
2026-10-16 15:44:25 +07:00
BFlat fec2ac402b Merge pull request #2357 from dotCooCoo/feat/simplify-more-options
Simplify: add Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path
2026-10-16 15:44:09 +07:00
BFlat 8593c15b90 Merge pull request #2359 from dotCooCoo/fix/rotated-view-canvas
Rotate View: fitting, the Zoom marquee, canvas guides and the Navigator follow a turned view
2026-10-16 15:43:52 +07:00
BFlat 6d4ad17954 Merge pull request #2365 from marcoarnulfo/feat/halftone
Effects: Effect › Sketch › Halftone Pattern redraws an object through a halftone screen of dots, rings or lines
2026-10-16 15:43:41 +07:00
BFlat c401df19d0 Merge pull request #2367 from dotCooCoo/feat/blob-brush-merge-selection
Blob Brush: Keep selected and Merge only with selection options
2026-10-16 15:43:24 +07:00
BFlat 743801b255 Merge pull request #2368 from dotCooCoo/feat/canvas-unlock-menu
Canvas: a right-click on locked objects offers Unlock with each object's name
2026-10-16 15:43:08 +07:00
BFlat f39b01174c Merge pull request #2370 from dotCooCoo/feat/ellipse-pie-widget
Ellipse: dragging a pie widget on the canvas sets its pie angle
2026-10-16 15:42:52 +07:00
BFlat 0f786df95e Merge pull request #2373 from dotCooCoo/feat/enclosed-marquee
Selection: pressing E while dragging a marquee selects only the objects inside it
2026-10-16 15:42:37 +07:00
BFlat 4ee4bff9b6 Merge pull request #2374 from Messhias/feat/symbol-export-type
G5: Symbol Options Export Type (Graphic / Movie Clip)
2026-10-16 15:42:21 +07:00
BFlat 233ad71bd6 Merge pull request #2376 from dotCooCoo/feat/shaper-mode-widget
Shaper: an arrow widget switches a Shaper Group between Face Selection and Construction mode
2026-10-16 15:42:04 +07:00
FredApps 0e97cf1d4b test(cad): round-trip closed DXF SPLINE flags through export and import (#1299)
#1320 (write) and #1318 (read) landed on main; this keeps only the missing
regression: explicitly closed control-point splines export with the closed
bit and re-import closed, while open paths (even with coincident endpoints)
stay open, across every SPLINE-capable DXF version.
2026-10-16 15:27:55 +07:00
Marco Arnulfo 830dea80e0 Docs: where the portable marker goes on macOS, AppImage and Flatpak; keep a portable copy where only you can write (#1366) 2026-10-16 15:27:55 +07:00
GameDirection 6fc79c33b7 Export for Screens: batch exporting with name patterns, folders and presets (#1935)
Issue #1935 asks for batch exports of many logo variations in many
formats, each format in its own folder. This builds on Export for
Screens, which already writes every chosen artboard in every format row.

Names: a file name pattern (`name`) names every file alike. Tokens:
<filename> (the document), <name> (the artboard, asset, layer or
object), <index>, a run of # (a counter, zero-padded to the run's
length, from counterStart; counterPer item or file), YYYYMMDD,
YYYY-MM-DD, <date>, <time>, <YYYY>, <MM>, <DD>, <format>, <scale> and
<suffix>. A trailing .ext is dropped. Words are not changed: only the
date patterns and the bracketed tokens are. `date` sets the day (the
default is today, UTC). Different files that a pattern names alike get
-2, -3... Without a pattern, two rows with one name still write the
first row only, as before.

Folders: `folders` puts all files in one folder, a folder per row (as
Create Sub-folders did), per extension, per item, or per item and then
extension.

What is exported: besides artboards, assets and the full document,
`layers` (each layer's art alone, sublayers included) and `selection`
(one file of the selection, or a file per object).

Formats: rows can also be EPS, AI and VectorCraft (a document of the
item alone, its one artboard). A PDF row with printer's marks (PDF
Print in the dialog: trim, registration, colour bars, page information,
suffix -print) sits next to a plain PDF row.

Presets: document.exportPresets.list, .save and .delete keep batch
export presets with the preferences, and `exportPreset` uses one under
the call's own params. Built-in: Logo Package (SVG, PNG 1x and 2x, PDF
and print PDF, a folder per format) and Print-Ready PDF.

Dialog: Layers and Selection tabs with thumbnails; an Export Preset
menu with Save and Delete; Folders; File Name with Insert for the
tokens, Start at, Prefix, and an example of the first file's name. The
OK button says Export Layer or Export Selection on those tabs.

Tests: the pattern tokens, the request's example (SVG, PNG, PDF and
print PDF for artboards 1-3, a folder per extension, the print PDF a
bigger page), folders by item and both, the counter by item and by
file, numbered duplicates, layers and the selection, EPS, AI and
VectorCraft per artboard, presets (built-in, saved, used, replaced,
deleted, kept with the preferences), bad params, and the dialog (every
tab, the example name, a pattern export into folders, the Layers and
Selection tabs, presets applied and saved). The three tests that
refused EPS and VectorCraft rows now check those files. New labels
are translated in all 19 catalogs.
2026-10-16 15:27:55 +07:00
dotCooCoo 24583effe0 Paragraph: the Hyphenation dialog sets each paragraph's hyphenation limits
The Paragraph panel menu's Hyphenation… was always disabled, and
automatic hyphenation used fixed limits: words of 6 letters or more,
3 letters on each side of the hyphen, any number of hyphenated lines
in a row, and capitalized words hyphenated.

Each paragraph now has hyphenation options: Words Longer Than, After
First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate
Capitalized Words. The defaults are the fixed limits, and documents
keep their line breaks. The options are saved only when they differ
from the defaults. text.setFormat takes wordsLongerThan, afterFirst,
beforeLast, hyphenLimit, hyphenationZone and hyphenateCapitalized, and
paragraph styles carry the options. Hyphen Limit applies with both
composers. Hyphenation Zone applies with the Single-line Composer: a
line whose text before the next word already ends within the zone
breaks before that word instead of hyphenating it.

Hyphenation… is enabled with type selected and opens a Hyphenation
dialog (ui.hyphenation) on the values the Paragraph panel shows. OK
runs text.setFormat with the values that changed, as one undo step.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 15:27:55 +07:00
Marco Arnulfo d21040d746 Portable mode: the marker beside the .app or .AppImage, a first-start copy of the profile, nothing under NO_PREFS (#1366)
The install folder is where the user sees the app: next to VectorCraft.app on
macOS (nothing is written inside the signed bundle) and next to the .AppImage
file on Linux ($APPIMAGE; the mounted executable's folder is read-only).

The first time VectorCraftData is created, the settings in the user's profile
are copied into it, so a portable zip that gains portable.txt keeps its users'
settings; the originals stay, a second start copies nothing, a copy that fails
halfway is logged and left as it is.

VECTORCRAFT_NO_PREFS never looks for the marker or creates the folder; a
portable copy skips the one-time read of DrawCraft's old settings; the
unwritable-folder warning goes to the log only (eprintln! panics on a closed
standard error).
2026-10-16 15:27:55 +07:00
Marco Arnulfo 9e44139641 Docs: deleting portable.txt returns settings to the user profile (#1366) 2026-10-16 15:27:55 +07:00
Marco Arnulfo 314bfe802f Portable mode: a portable.txt beside the executable keeps the settings in VectorCraftData (#1366) 2026-10-16 15:27:55 +07:00
turtwig b795d15d69 Artboard tool: a double-click on an object fits one artboard, and the canvas name renames
A double-click on an object or group fits one artboard to the visible bounds and does not open Artboard Options. The second click does not fit another artboard. Other artboards stay. Double-clicking empty artboard space still opens Artboard Options. Double-clicking the name drawn above an artboard renames it.
2026-10-16 15:27:54 +07:00
bflatastic 05b160b32e Fix Image Trace Noise splitting previously merged regions
Squashed from #1368 onto main.
2026-10-16 15:27:54 +07:00
bflatastic 84bd59de36 Image Trace: retain pieces returning to a junction
Squashed from #2205 onto main.
2026-10-16 15:27:54 +07:00
Gert Pellin 065e237f7f Align Stroke on live type: tests for the Type tool's selection, docs (#2294)
The Stroke panel enables Align Stroke while the Type tool edits text, and
styles the selected characters. docs: mcp.md (stroke.set and strokeOptions
align on type), type-parity and target-app-parity rows.
2026-10-16 15:27:54 +07:00
dotCooCoo adea2d307d Print: Print and Print Preview warn about type that doesn't fit its frame
Area type and type on a path whose text doesn't fit print without the
text past the end of the frame. text.create, text.createInPath,
text.setText and text.setStyle report it in their replies, but
file.print and print.preview gave no warning, and the Print dialog's
Summary showed none.

Both commands now add a warning that counts the area type and type on
a path that prints and doesn't fit, and gives their ids (the first 20,
then how many more). It counts the objects that the fallback font
warning counts: visible objects on the layers that Print Layers picks,
on the paper of the artboards or tiles that print. The walk that finds
the fonts of the printed type also collects these ids. Type in the art
of symbols, patterns, brushes, opacity masks and placed documents is
not named.

Docs: docs/mcp.md (Print, Fitting area type), docs/target-app-parity.md
(Type).
2026-10-16 15:27:54 +07:00
dotCooCoo dfe215ca30 Effects: Effect › Path › Outline Object turns type into live, editable outlines
Effect › Path had Offset Path and Outline Stroke but not Outline Object.
The new effect (path.outlineObject) has no options: the Effect menu
applies it at once, and effect.apply refuses any parameter. It is a
geometry effect that returns its input. Type already reaches geometry
effects as its glyph outlines, so type that carries it is drawn,
exported (PDF and SVG as paths, with no font) and expanded as those
outlines, stays editable type, and the effects after it reshape the
outlines. Paths, compound paths and images draw as before, and no
bounds change: with Use Preview Bounds on, type is still measured by
its layout box.

Every complete catalog gets a row for the label.

Docs: docs/effects-parity.md, docs/target-app-parity.md (an evidence
line), docs/mcp.md (the effect, and what Expand Appearance does to
type).
2026-10-16 15:27:54 +07:00
dotCooCoo bc8e42d8c7 Export: the art's bounds take in the effects of groups, type and images
The bounds of the visible art (`vectorcraft_render::encode::art_bounds`)
are the crop of Export with Use Artboards off, EPS without artboards,
Export for Screens' Full Document and Save for Web (Legacy) with Clip to
Artboard off, and the size Fit to Artwork Bounds gives an artboard. They
measured a group or layer by its members and left out its own effects,
and measured type and images without their effects. A group's drop
shadow, or the shadow of a line of type, was cut off at the image's edge
and left outside the fitted artboard. They also counted a Shaper group's
transparent source art.

Each visible object is now measured by `vectorcraft_render::effect_bounds`,
the measure of Export Selection's crop. A group or layer without effects
of its own is still measured by its visible members, skipping a Shaper
group's source art. A group or layer with a drop shadow, a glow or another
raster effect is measured without its hidden members, guides and template
layers, unless it is a Shaper group or also has a geometry, Pathfinder,
Crop Marks or 3D effect or a visible effect on its own fill or stroke.
The SVG of a chosen artboard also keeps an object whose effects alone
reach the artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 15:27:54 +07:00
dotCooCoo d973805269 Type: geometry effects on a type object's own fills and strokes are drawn and exported
A geometry effect applied to one of a type object's own fills or
strokes (effect.apply with item) was kept but not drawn or exported
unless the type also had a geometry effect or perspective of its own.
With a geometry effect, the renderer measured the type for culling
without the fill's or stroke's effects, so an outline that reached past
the glyphs was not drawn once the glyphs left the view.

Type whose own fills or strokes have a visible geometry effect is now
drawn, culled and exported through its outlines, the same way as type
with an object-level geometry effect. Each effect reshapes the whole
outline that its fill or stroke paints. SVG, PDF, EPS and the other
vector exports write such type as paths. Type that holds inline
graphics stays type, as before.
2026-10-16 15:27:54 +07:00
dotCooCoo 56acb8d3b6 Select: Update Selection replaces the recalled saved selection with the current selection
The Select menu had no Update Selection: to add objects to a saved
selection or take objects out of it, the selection had to be saved
again under the same name. Select › Update Selection (`select.update`)
now gives the saved selection last recalled the objects that are
selected, in one undo step. It is enabled while objects are selected
and the document still holds that saved selection.

Each open document keeps session ids for its saved selections, so the
recalled one is found under a new name after Edit Selection…, and after
Undo and Redo. Deleting it disables Update Selection until another
saved selection is recalled or the delete is undone.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 15:27:54 +07:00
Marco Arnulfo 0ca6503f09 Canvas: scroll bars along the window's right and bottom edges (#2322) 2026-10-16 15:27:54 +07:00
Gert Pellin ca0673e0bc Align Stroke inside or outside on live type (#2294)
The characters' stroke takes Align Stroke like an object stroke: CharStyle
keeps stroke_align (written only when not centred), stroke.set and
text.setRangeStyle strokeOptions apply align to type, and the Stroke panel
enables the buttons for type. Canvas, PDF and EPS draw it around the glyphs
through stroke_layer(); SVG keeps the text live and writes the aligned
stroke on the glyph outlines, clipped or masked. Type's visual bounds grow
with its character strokes.
2026-10-16 15:27:54 +07:00
dotCooCoo 917dcdeeef Simplify: add Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path
The Simplify dialog had Tolerance, Preview and the anchor point counts.
It now also has three of the options of the reference app's advanced
Simplify dialog:

- Corner Point Angle Threshold is a slider from 0° to 180° that opens
  at 150°, with its label above it so the dialog stays under the
  420-point width that menu_parameter_dialogs_are_compact allows. An
  anchor whose corner is sharper than the threshold stays a corner, so
  moving the slider right keeps more corners. The dialog sends
  object.path.simplify's cornerAngle as 180° less the threshold, so the
  opening value gives the same result as before.
- Convert to Straight Lines sends straightLines.
- While Preview is on, Show Original Path draws the paths as they were
  before the preview, in a faint band of their layer's color under the
  selection outlines.

The three labels have rows in the 19 catalogs that translate every
label.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
2026-10-16 15:27:53 +07:00
dotCooCoo 25d348f391 Rotate View: fitting, the Zoom marquee, canvas guides and the Navigator follow a turned view
In a rotated view, seven parts of the canvas and the Navigator worked as
if the view were not turned. Fit Artboard in Window and Fit All in
Window sized the zoom from the unturned width and height, so at 45° a
612 x 792 pt artboard ran off a 1000 x 800 canvas. The Zoom tool's
marquee (Animated Zoom or GPU Performance off) set the zoom to 3.13% at
90° and 180°. Ruler guides on no artboard were drawn square to the
screen while the tools pick them, snap to them and move them by their
position in the document. The rectangle a tool drags out (the selection
marquee, and the drags of the Type tool, the Artboard tool, the Place
cursor and Rectangle Cut) was drawn as a box square to the screen while
the tools work in the document rectangle. Show Hidden Characters skipped
type whose turned outline reached into the canvas when the box between
two of its corners did not. At 180° the Contextual Task Bar covered the
selection, and the Navigator's red box outlined an area that the window
did not show.

Now fitting uses the size of the artboard or art turned with the view,
and the Zoom tool's marquee zooms by its size on screen and centers on
its middle. A canvas guide is drawn along its document line, cut to the
canvas. Rectangles that tools drag out are drawn through the screen
corners of the document rectangle the tools use. Show Hidden Characters
uses the type's turned outline on screen, and the task bar uses the
selection's. The Navigator draws the canvas's corners mapped into the
document. At 0° each of these does what it did before.

Docs: docs/target-app-parity.md.
2026-10-16 15:27:53 +07:00
Marco Arnulfo 403b335039 Effects: Effect › Sketch › Halftone Pattern redraws an object through a halftone screen of dots, rings or lines 2026-10-16 15:27:53 +07:00
dotCooCoo ddfd08f321 Blob Brush: Keep selected and Merge only with selection options
A Blob Brush stroke merged with every touching closed path of its
color that had no stroke, and the blob it drew or merged into was
always left selected. The Blob Brush Tool Options had Size only.

The Blob Brush Tool Options now have Keep selected and Merge only
with selection, both off by default. A blob that a stroke draws or
merges into is no longer left selected unless Keep selected is on,
and the rest of the selection stays as it was. While Merge only with
selection is on, a stroke merges only with selected blobs, and with
nothing selected it draws a new blob. The options persist like the
tool's Size, and path.blob takes keepSelected and mergeOnlySelection,
both false by default. Keep selected stays on by default for the
Pencil and Paintbrush.

Docs: docs/target-app-parity.md, docs/control-protocol.md.
2026-10-16 15:27:53 +07:00
dotCooCoo f0d3f81f3b Canvas: a right-click on locked objects offers Unlock with each object's name
A right-click on the canvas now records the locked objects under the
pointer with vectorcraft_doc::hit::locked_objects_at, topmost first and
at most 20. A locked group is listed instead of the objects it holds.
Hidden objects and objects on hidden, locked or template layers are not
listed, and in isolation mode only the isolated group's contents are
listed. While the menu is open, it has an Unlock submenu after Undo
and Redo that lists each of them by the name the Layers panel shows.
Choosing a name runs layer.setProps {ids: [id], locked: false}, as the
Layers panel's lock column does, in one undo step. ui.contextMenu.list
shows the same items.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 15:27:53 +07:00
dotCooCoo 3b683bbeec Ellipse: dragging a pie widget on the canvas sets its pie angle
The Selection tool shows two pie widgets just outside a selected live
ellipse while the bounding box shows: one at the Pie Start Angle and
one at the Pie End Angle. Dragging one sets its angle to the angle of
the outline's point that lies in the direction of the pointer from the
ellipse's center, in one undo step, through `object.setLiveShape`'s
`pieStart` or `pieEnd`. Double-clicking one makes the ellipse whole
again (0° to 360°). Where the two widgets coincide, as on a whole
ellipse, a press takes the End widget.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
2026-10-16 15:27:53 +07:00
dotCooCoo 5527418234 Selection: pressing E while dragging a marquee selects only the objects inside it
A Selection tool marquee selected every object it touched, a Group
Selection marquee selected the anchors inside it, and E pressed during
the drag did nothing. Now E switches either marquee to selecting only
the objects that lie entirely inside it, measured by their geometric
bounds, and E pressed again switches it back. Each marquee starts in
the default mode.

- The Selection tool selects a group only when the whole group lies
  inside. Group Selection selects the objects inside groups, each as a
  whole object. Shift still toggles. Direct Selection does not take E.
- The ruler guides the marquee selects with the art are limited the same
  way: an artboard guide is selected when its whole length lies inside,
  and a canvas guide is not selected.
- Tools receive letter keys as ToolKey::Letter. While a tool is busy, the
  desktop app sends it each press of a letter it claims, without Cmd,
  Ctrl or Alt and without key repeats, and headless press_key sends a
  claimed letter the same way. With nothing dragged, press_key {key: "e"}
  still chooses the tool bound to E.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 15:27:53 +07:00
Fabio 38dd835af6 G5: Symbol Options Export Type (Graphic / Movie Clip)
Store Export Type on symbols for Animate handoff; New Symbol and Symbol Options set it, and symbol.list reports it.
2026-10-16 15:27:52 +07:00
BFlat 2269a58887 Merge pull request #2372 from dotCooCoo/feat/type-reset-hscale
Type: Cmd/Ctrl+Shift+X resets horizontal scale to 100%
2026-10-16 15:18:19 +07:00
BFlat 7bbb63e4bc Merge pull request #2381 from dotCooCoo/feat/type-size-by-five
Type: Cmd/Ctrl+Alt+Shift+. and , step the font size by five increments
2026-10-16 15:17:22 +07:00
BFlat 6b06cba32d Merge pull request #2314 from dotCooCoo/feat/pdf-export-user-unit
PDF export: a page larger than 14,400 pt is written with a UserUnit
2026-10-16 15:17:06 +07:00
BFlat 6af7769f08 Merge pull request #2315 from dotCooCoo/fix/fit-artboard-to-its-art
Artboards: Fit to Artwork Bounds fits each selected artboard to the art on it
2026-10-16 15:16:50 +07:00
BFlat 8c21a7710d Merge pull request #2313 from dotCooCoo/feat/transform-ctrl-enter-proportional
Transform: Ctrl/Cmd+Enter in W or H scales the selection proportionally
2026-10-16 15:16:35 +07:00
BFlat 84e2da4bdd Merge pull request #2336 from dotCooCoo/feat/rotate-view-to-selection
View: Rotate View to Selection turns the view square to the selected object
2026-10-16 15:16:18 +07:00
BFlat 878d1f231a Merge pull request #2337 from dotCooCoo/feat/distribute-objects-key-spacing
Align: Distribute Objects uses the spacing value with a key object
2026-10-16 15:16:01 +07:00
BFlat 012395d49d Merge pull request #2349 from dotCooCoo/feat/escape-resets-rotation
Rotate View: Escape pressed twice resets a rotated view
2026-10-16 15:15:46 +07:00
BFlat ef32703362 Merge pull request #2352 from VL420/fix/remove-anchors-select-next
G1: Remove Anchor Points selects the next anchor (#2241)
2026-10-16 15:15:32 +07:00
BFlat 156b492119 Merge pull request #2309 from dotCooCoo/fix/tab-keeps-control-bar-state
Window: a second Tab shows only the bars the first Tab hid
2026-10-16 15:15:17 +07:00
BFlat 236e908dc9 Merge pull request #2335 from dotCooCoo/fix/macos-unfocused-cursor
Cursors: the canvas shows only the system pointer on macOS while another app or a sheet has the focus
2026-10-16 15:15:01 +07:00
BFlat 6dc14c30a9 Merge pull request #2329 from dotCooCoo/feat/swatch-alt-drag-replace
Swatches: Alt-dragging a swatch onto another replaces that swatch's color
2026-10-16 15:14:46 +07:00
BFlat eb50f50599 Merge pull request #2330 from marcoarnulfo/fix/anchor-point-click-retracts-handle
Anchor Point tool: a click on a handle end retracts that handle only
2026-10-16 15:14:31 +07:00
BFlat 700df5dbe4 Merge pull request #2312 from marcoarnulfo/feat/line-extension-guide
Fix #2120: a nearly level or upright segment still lines up with its other end; Curvature keeps to the extension too
2026-10-16 15:14:15 +07:00
BFlat 7fee2c762f Merge pull request #2317 from switch87/fix/2269-ai-rotated-raster
.ai import: a rotated image in the editing data lands where its raster matrix puts it (#2269)
2026-10-16 15:13:58 +07:00
BFlat 6cdd63638f Merge pull request #2318 from dotCooCoo/fix/data-set-reports-overflow
Variables: selecting a data set reports the area type it overflows
2026-10-16 15:13:43 +07:00
BFlat 2df4fede2a Merge pull request #2344 from VL420/feat/task-bar-vectorize
G1: Image Trace, Crop Image and Expand in the Contextual Task Bar
2026-10-16 15:13:28 +07:00
BFlat ee1203d463 Merge pull request #2351 from dotCooCoo/fix/dataset-csv-quoted-spaces
Variables: a quoted text cell in a data set CSV keeps its leading and trailing spaces
2026-10-16 15:13:13 +07:00
BFlat 98a988eaf4 Merge pull request #2358 from dotCooCoo/fix/ai-text-on-nonprinting-layer
.ai import: text on a layer that doesn't print stays on that layer
2026-10-16 15:12:58 +07:00
BFlat e69b25faa6 Merge pull request #2371 from marcoarnulfo/fix/window-properties-toggle
Window menu: a panel's item hides it while it shows and shows it again where it was
2026-10-16 15:12:43 +07:00
BFlat c69463782a Merge pull request #2377 from marcoarnulfo/fix/isolated-compound-member-moves
Isolation mode: a path of the isolated compound path moves on its own
2026-10-16 15:12:27 +07:00
BFlat 07e7d14f43 Merge pull request #2362 from AyberkA0/mcp-command-list-filter
MCP: command listings take query, menu and brief (#2173)
2026-10-16 15:12:16 +07:00
BFlat 17736b6fe3 Merge pull request #2356 from Silvertree2010/fix/2286-bug-report-vectorcraft-entering
Keep Layers panel selection inside the isolated group
2026-10-16 15:12:00 +07:00
BFlat d12f75afcd Merge pull request #2363 from Silvertree2010/fix/h1981-find-and-replace-shifts-paragrap
Keep paragraph styles aligned in Find and Replace with line breaks
2026-10-16 15:11:44 +07:00
BFlat 5e01868020 Merge pull request #2366 from AyberkA0/font-menu-star-scrollbar
Font menu: the scroll bar no longer covers the favourite stars (#2277)
2026-10-16 15:11:28 +07:00
BFlat 6f129beb86 Merge pull request #2341 from marcoarnulfo/fix/graph-type-dropdown
Graph Type: the graph kind is a dropdown of the nine types
2026-10-16 15:11:12 +07:00
BFlat a282e4d2bf Merge pull request #2347 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-16 15:10:57 +07:00
BFlat cafe3d658c Merge pull request #2350 from dylanmtaylor/ci/standardize-freebsd-15
ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action
2026-10-16 15:10:42 +07:00
BFlat 9493e6f91d Merge pull request #2355 from kler/swedish-ui-review-2026-10-16
i18n(sv): review of the rows drafted for the strings main added
2026-10-16 15:10:24 +07:00
BFlat 5cb8b1ba07 Merge pull request #2316 from marcoarnulfo/credits/marcoarnulfo
Contributors: add Marco Arnulfo
2026-10-16 15:10:13 +07:00
dotCooCoo 21b4e70150 Shaper: an arrow widget switches a Shaper Group between Face Selection and Construction mode
A Shaper Group had no arrow widget for switching between Face Selection
and Construction mode.

With the Shaper or Selection tool active, a selected Shaper Group, or a
group with one of its faces selected, now shows an arrow beside the
middle of the right side of its source shapes' bounds. A click on it
isolates the source shapes (object.isolate). A second click leaves
isolation with the group selected (object.exitIsolation). The arrow
points right in Face Selection mode and left in Construction mode.

In Construction mode a Shaper click selects an original shape of the
isolated group. Before, it selected the whole group outside the
isolation, and shaper.select with source could pick an original of
another Shaper Group lying above it.

Docs: docs/shaper.md, docs/target-app-parity.md.
2026-10-16 15:00:07 +07:00
dotCooCoo 6dfdf643d9 Type: Cmd/Ctrl+Shift+X resets horizontal scale to 100%
The reference app resets the horizontal scale of type to 100% with
Cmd/Ctrl+Shift+X. VectorCraft had no command for it, and the chord
cut the selection: egui reports it as a Cut with Shift held.

type.resetHorizontalScale (Reset Horizontal Scale, default
Cmd+Shift+X) sets the horizontal scale to 100% in the text the Type
tool has selected, or else in all the text of every selected type
object, in one undo step. The value is the one the Character panel
shows, so a type object stretched to twice its width keeps 50% stored.
The vertical scale does not change. At a caret the command leaves the
text as it is. Type objects given in ids are reset in full, as
type.resetTracking does.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:58:09 +07:00
dotCooCoo ad1e8d1112 Type: Cmd/Ctrl+Alt+Shift+. and , step the font size by five increments
No command held Cmd/Ctrl+Alt+Shift+. and Cmd/Ctrl+Alt+Shift+,, so the
chords reached Increase and Decrease Font Size (Cmd/Ctrl+Shift+. and ,)
and changed the size by one Size/Leading increment.

Two commands, type.size.increaseByFive and type.size.decreaseByFive,
now hold the chords. They step the font size of the selected type, or
of the text the Type tool has selected, by five times the Size/Leading
increment (Preferences › Type). Agents reach them with press_key too.
Their labels have rows in every complete catalog.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:58:09 +07:00
dotCooCoo f46b0f7dc4 PDF export: a page larger than 14,400 pt is written with a UserUnit
PDF export and .ai saves wrote every page in points. ISO 32000-1
(Annex C.2) gives 14,400 units as the longest page side, and a page
with a longer side got a media box past that limit. A PDF/A-2b file
with such a page failed veraPDF's page size rule (6.1.13).

Such a page now gets a UserUnit (PDF 1.6): its longer side divided by
14,400 and rounded up to the next thousandth, with the page's sizes
and coordinates in those units. A 20,000 × 10,000 pt artboard is
written as a 14,398.85 × 7,199.42 page with /UserUnit 1.389, and
VectorCraft's PDF import reads it back at 20,000 × 10,000 pt. Pages
up to 14,400 pt, PDF 1.3 to 1.5 files, PDF/X files and printing are
written as before.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:58:09 +07:00
dotCooCoo cc3299dc0f Artboards: Fit to Artwork Bounds fits each selected artboard to the art on it
Fit to Artwork Bounds resized the active artboard to the bounds of all
the art in the document, wherever that art was. With art on two
artboards, fitting one of them stretched it over both.

artboard.fitToArt now takes `indices` and fits each artboard to the
art on it, in one undo step. An object counts for every artboard its
drawn bounds overlap, and the artboard is fitted to all of its bounds,
also where it reaches past the artboard's edge. Art flush with an edge
from outside does not count. An artboard with no art on it keeps its
size and place, and the command fails with "no artwork" when none of
its artboards has art. Object › Artboards and the Artboards panel menu
pass the selected artboards (the Artboard tool's, else the active one),
and a recorded action leaves them out. Fit to Selected Art still
resizes the active artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:58:09 +07:00
dotCooCoo 467c47b719 Transform: Ctrl/Cmd+Enter in W or H scales the selection proportionally
In the Transform panel, the Properties panel's Transform section and
the Control bar, Ctrl+Enter (Cmd+Return on a Mac) in W or H left the
field active and applied nothing. Now it applies the value as Enter
does and sends `proportional: true` to `object.setBounds`, so the other
side scales by the same factor with the Constrain Width and Height
Proportions link off. With the Artboard tool, the Transform panel's W
and H keep the active artboard's proportions the same way. X, Y,
Rotate, Shear and the number fields of dialogs keep Ctrl/Cmd+Enter as
it was.

- widgets::size_field is a num_field that Ctrl/Cmd+Enter also commits.
- transform::proportional gives the `proportional` param of the W and
  H fields: the link, or true after Ctrl/Cmd+Enter.

Docs: docs/target-app-parity.md.
2026-10-16 14:58:09 +07:00
dotCooCoo 03ffe790be View: Rotate View to Selection turns the view square to the selected object
A rotated object's bounding box stands at the object's angle. No
command turned the view to the angle of the selection: `view.rotate`
turns the view only to an angle passed to it.

View › Rotate View to Selection, between the Rotate View submenu and
Reset Rotate View, runs a new UI command, `view.rotateToSelection`. It
turns the view about the center of the canvas to the angle the
selected objects share, the angle `document.inspect` reports as
`selectionRotation`, so their bounding box is square to the window.
Objects turned to different angles share no angle, and the view turns
back to 0. The zoom and the view's center stay as they were. The
command returns `{angle}`. With nothing selected it returns "nothing
selected" and the menu item is disabled; without a document it
returns "no document".

Every catalog that translates the menus has a new "Rotate View to
Selection" row.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:58:08 +07:00
dotCooCoo ebc2857549 Align: Distribute Objects uses the spacing value with a key object
With a key object, the Align panel's six Distribute Objects buttons
ignored the Distribute Spacing value. They kept the outermost objects
in place, spaced the others evenly between them and could move the
key object.

object.distribute now takes spacing. While the selection has a key
object, the key object stays in place, with the compound path or
selected group that contains it, and the objects' left edges, centers
or right edges (top edges, centers or bottom edges) end up spacing
points apart, outward from the key object's, in their order on that
line. With 0, the field's default, every object ends on the key
object's line. Two objects are enough, and to and artboard are not
used. The panel sends the value with the Distribute Objects buttons
while there is a key object, as it does with the Distribute Spacing
buttons. Without a key object, or with anchor points selected,
object.distribute does not use spacing and works as before. A spacing
that is not a number is an error.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:58:08 +07:00
dotCooCoo fb13a1e1f1 Rotate View: Escape pressed twice resets a rotated view
Escape never changed the view's rotation.

Now a second Escape less than a second after the first runs Reset
Rotate View (`view.rotateReset`) when the view is rotated and the
second press has nothing else to end: no Presentation Mode, pattern
editing, isolation mode or open tool flyout, no tool that is busy or
takes the key, no open menu, dialog, command palette or Edit Toolbar
drawer, no text field or input method that takes the key, no number
field being dragged and no drag of the ruler origin. The first Escape
may end something, such as isolation mode, but it does not count as the
first when a menu, a dialog, the command palette, the drawer, a field
or an input method takes it. After a click, a press of another key that
is not a modifier (Shift, Control, Alt/Option or Command) or a held
Escape's repeats, the next Escape counts as the first. With the Rotate
View tool chosen, one Escape resets the view.

Docs: docs/target-app-parity.md.
2026-10-16 14:58:08 +07:00
VictorandClaude Opus 5.5 d049e69b95 G1: Remove Anchor Points selects the next anchor (#2241)
After Object › Path › Remove Anchor Points the anchor that follows each
removed one is selected: round the end of a closed subpath, or the one
before it at an open subpath's end. Running the command again goes on
along the path, without picking each anchor first. Several anchors,
subpaths and paths each get their own next anchor; a path that goes
leaves nothing selected; one undo step restores the anchors and the
selection.

The Delete key still selects the remainder whole so a second Delete
removes it, and the Delete Anchor Point tool still leaves the path
selected whole.

Fixes #2241

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:58:08 +07:00
dotCooCoo 288037543e Window: a second Tab shows only the bars the first Tab hid
Tab set the panels, the toolbar and the Control bar to one value on
each press. Two presses turned on a Control bar that had been off.
When the toolbar was hidden with Window › Tools, Tab showed the toolbar
and the Control bar instead of hiding the panels.

Tab now records which of the three showed when it hides them, and the
next Tab shows those again. Panels shown with Shift+Tab in between
stay shown. While the panels are hidden and the toolbar or the
Control bar shows, as after Shift+Tab, Tab hides those bars instead of
showing all three. When the toolbar or the Control bar was shown another way
after Tab hid them, the next Tab hides what shows and records that
instead. A switch of workspace clears the record. When none of the
three shows and Tab hid none, Tab shows all three, as before. The
record is saved with the other UI settings, so the first Tab after a
restart shows the same bars.

Docs: docs/target-app-parity.md.
2026-10-16 14:58:08 +07:00
dotCooCoo f554c699a1 Cursors: the canvas shows only the system pointer on macOS while another app or a sheet has the focus
On macOS the canvas paints the tool cursor into the window and hides
the system pointer. winit hides the pointer with a cursor rect that
holds a transparent cursor, and AppKit applies cursor rects only in the
key window. Over a VectorCraft window behind another app, or under its
own File › Open… sheet, macOS showed its arrow next to the painted tool
cursor.

On macOS the canvas now paints the tool cursor only while the window
has the focus. Without the focus it sets the tool's system cursor icon,
and the system pointer shows alone. Windows, X11, Wayland and the web
are unchanged.

Part of #2020.

Docs: docs/target-app-parity.md.
2026-10-16 14:58:08 +07:00
dotCooCoo 9336111655 Swatches: Alt-dragging a swatch onto another replaces that swatch's color
In the Swatches panel, a swatch dragged onto another swatch moved next
to it whether or not Alt was held. With Alt held at the release, a
swatch dropped on a swatch of its kind now gives that swatch its color,
gradient or pattern in one undo step, and the swatch keeps its name and
place. Art linked to a global swatch takes the new color. Drags of
None, Registration or color groups, and drops on a tint swatch or on a
swatch of another kind, work as before.

A color dragged in from outside the panel, such as from a Fill/Stroke
proxy, and Alt-dropped on a tint swatch failed with an error that the
swatch is a tint. It now becomes a new swatch, as a drop on a swatch of
another kind does.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 14:58:08 +07:00
Marco Arnulfo bb66070841 Anchor Point tool: a click on a handle end retracts that handle only (fixes #2267) 2026-10-16 14:58:08 +07:00
Marco Arnulfo f88242f576 Fix #2120: a nearly level or upright segment still lines up with its other end; Curvature keeps to the extension too 2026-10-16 14:58:08 +07:00
Gert Pellin b9069e761e .ai import: a rotated image in the editing data lands where its raster matrix puts it (#2269)
The raster matrix [a b c d tx ty] is an art-space matrix (y up) applied to
the pixels with their rows running downwards, (u, -v). The reader flipped
the matrix's result instead, which only agrees when b = c = 0, so a rotated
image came in turned 180 degrees around its corner. The legacy version 7
writer and its prolog used the same convention; they now write and draw
the matrix the reader takes.
2026-10-16 14:58:08 +07:00
dotCooCoo 88088e99bb Variables: selecting a data set reports the area type it overflows
dataset.select, dataset.next and dataset.prev rewrite bound type with
the data set's text but replied only {name, applied, skipped}. A long
value could push lines out of a fixed frame and the reply had no
overflow key, while text.setText answers overflow: [id] for the same
text.

The three commands now add overflow: [id...] with the area type and
type on a path whose text they rewrote and that doesn't fit, through
typecmd::with_overflow, as text.setText does. An object that two text
variables rewrite is named once. Type that the data set left alone is
not named, and the key is left out when all of the rewritten text
fits.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:58:08 +07:00
Marco Arnulfo 54a8d60dae Fix #2120: Smart Guides keep a dragged end of a straight segment on its extension at any angle 2026-10-16 14:58:08 +07:00
VictorandClaude Opus 5.5 a3adaca19e G1: Image Trace, Crop Image and Expand in the Contextual Task Bar
As in the reference app's Contextual Task Bar, which offers "options like
Image Trace, Retype, and Crop Image" for images and lets you "quickly
expand traced artwork from the bar" (public announcement:
https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580):

- One image selected: Image Trace traces it at once with the Default
  preset, leaving the Image Trace panel as it was (like the quick trace
  from Properties and the Control bar); Crop Image shows the crop box.
  Offset Path and Simplify, which don't apply to an image, are gone.
- One Image Trace object selected: Expand keeps only the traced paths,
  in place of Ungroup and Isolate.

Retype isn't offered: the app doesn't have it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:58:07 +07:00
dotCooCoo 837d2b5667 Variables: a quoted text cell in a data set CSV keeps its leading and trailing spaces
dataset.import trimmed every cell, quoted or not. dataset.export quotes
a text value with leading or trailing spaces, so a value such as
"  padded " came back from its own export as "padded".

The CSV reader now trims an unquoted cell as before and keeps a quoted
cell as written between its quotes, spaces included. White space
between the closing quote and the next comma or line end is still
dropped. Image, graph and visibility cells, data set names and header
cells are trimmed as before.
2026-10-16 14:57:25 +07:00
dotCooCoo 04806a98bf .ai import: text on a layer that doesn't print stays on that layer
A `.ai` opened from its editing data lost a text object on a layer that
shows and has Print off when its page drew no type for it. prune_unseen
weighs each text object made from the text document that the page's
type doesn't match against the page, and the layers are drawn for that
comparison as they print (as_printed), with such layers hidden. Taking
one of these objects out never changed the comparison, so each was
removed with the note "text objects that the file keeps but its page
doesn't draw ... are left out".

unmark now carries a print flag next to the shown flag, and a layer
with printable off clears it for everything it holds. Only made text
objects that show and print are weighed against the page; the others
stay on their layer.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 14:57:24 +07:00
Marco Arnulfo c91405dbea Window menu: a panel's item hides it while it shows and shows it again where it was (part of #2361) 2026-10-16 14:57:23 +07:00
Marco Arnulfo 80b7aee756 Isolation mode: a path of the isolated compound path moves on its own (fixes #2354) 2026-10-16 14:57:22 +07:00
Ayberk A. d838af024b MCP: command listings take query, menu and brief (#2173)
The command catalogue is about 340,000 characters, more than many MCP
clients show, and `query` (the name agents reach for first) was refused as
an unknown argument. command_list and list_commands now take `query` as
another name for `filter`, `menu` to keep the commands under a menu path
("Object > Path"), and `brief` to leave out each command's params text,
which is most of the catalogue; the params of one command are a filtered
listing away.
2026-10-16 14:57:21 +07:00
NetterSilverandClaude Haiku 5.5 e510190867 Keep Layers panel selection inside the isolated group
Fixes #2286

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:57:21 +07:00
NetterSilverandClaude Haiku 5.5 ee6cd6de55 Keep paragraph styles aligned in Find and Replace with line breaks
Found while testing crates/engine/src/cmd/typemenu.rs:668 (find_replace / edit_runs)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 14:57:20 +07:00
Ayberk A. f14f8d8eb7 Font menu: the scroll bar no longer covers the favourite stars (#2277)
The family list scrolled with egui's floating scroll bar, which is drawn
over the rows' right ends, where each family's star is: with the pointer
there the bar widened over the star and a click went to the bar. The list
now takes the scroll bar the menus got for the same reason (#2086), slim
and in a column of its own; `widgets::list_scroll_style` holds it for both.
2026-10-16 14:57:19 +07:00
Marco Arnulfo 3b00334c41 Graph Type: the graph kind is a dropdown of the nine types (fixes #2175) 2026-10-16 14:57:18 +07:00
Dylan M. Taylor e9edc49977 ci: switch to shared nix workflow from craft-common-actions 2026-10-16 14:57:18 +07:00
Dylan M. Taylor f9092edfe3 ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action 2026-10-16 14:57:17 +07:00
Patrik LermonandClaude Opus 5.5 54e43d0a97 i18n(sv): review of the rows drafted for the strings main added
- "Font Style:" in Dimension Tool Options is Teckensnittsstil: Teckenstil
  is already Character Style.
- The drawing-scale help reads "1:10 visar 1 enhet på sidan som 10".
- The layered-file fallback message and "Check an artboard to save" read
  more naturally.
- "File › Place…" names the menu as the menus do (Fil, not Arkiv).
- The rows are reviewed, so the "machine-drafted; review welcome" note
  above them goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:57:16 +07:00
Marco Arnulfo c5e38a309c Contributors: add my name for the About window 2026-10-16 14:57:15 +07:00
BFlat 842656f240 Merge pull request #2378 from storytold/ui/classic-theme
UI: Classic colour scheme
2026-10-16 14:39:52 +07:00
477 changed files with 59539 additions and 3086 deletions

No files matched your search

+1 -44
View File
@@ -36,47 +36,4 @@ jobs:
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
package:
name: FreeBSD 14 (package)
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
# and on demand, not on every push to main.
if: github.event_name != 'push'
runs-on: ubuntu-latest
timeout-minutes: 150
steps:
- 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: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
mem: 12288
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
# The craft-fonts checkout is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
# Every part the package must have, the craft-fonts licences included.
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: freebsd-package
path: dist/release/*
if-no-files-found: error
run-package-check: true
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
Generated
+30 -28
View File
@@ -4507,7 +4507,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4542,7 +4542,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4553,7 +4553,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4565,7 +4565,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4581,7 +4581,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4603,7 +4603,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4615,7 +4615,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4632,7 +4632,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4648,7 +4648,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4684,9 +4684,11 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"fax",
"flate2",
"hayro-ccitt",
"image",
"kurbo 0.13.1",
"ruzstd",
@@ -4705,7 +4707,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"proptest",
@@ -4720,7 +4722,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4732,7 +4734,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"log",
@@ -4751,7 +4753,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4765,7 +4767,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4777,7 +4779,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"aes",
"craft-pdf",
@@ -4807,7 +4809,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -4821,7 +4823,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"gif",
@@ -4848,14 +4850,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4879,7 +4881,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4896,7 +4898,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4916,7 +4918,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4924,7 +4926,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4940,7 +4942,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4952,7 +4954,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-ui",
"egui",
@@ -4986,7 +4988,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -6096,7 +6098,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+4 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -79,6 +79,9 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
rustix = { version = "1.1", default-features = false }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through
# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version.
hayro-ccitt = "0.3"
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
+1 -1
View File
@@ -245,7 +245,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 (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) 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.
+8 -5
View File
@@ -9,7 +9,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-16 · **Change:** minor (about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -29,7 +29,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 29/57, 3D 4/4 (initial types, no materials) |
| **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 33/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@@ -76,11 +76,11 @@ 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) | 49% | 23–39 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 |
| 3D and Materials | ~25% | 30–55 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
| Symbols, blends, envelopes, Repeat, perspective | 87% | 5–9 |
| Image Trace, graphs, image tools | 75% | 5–8 |
| Layers, artboards, document setup | 78% | 5–8 |
| View & navigation | 70% | 9–14 |
@@ -123,7 +123,7 @@ 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: 28 filters and the Effect Gallery, 23–39 h.
5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log
@@ -134,6 +134,9 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
+15 -5
View File
@@ -159,6 +159,16 @@ fn main() -> ExitCode {
}
}
/// A headless session that remembers what the user keeps between runs: the PDF presets saved, the
/// ICC profiles loaded and the plug-ins installed (#2168, #2174, #2541), in the folders next to the preferences that the desktop
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
fn with_stores(mut h: Headless) -> Headless {
prefs_dir::attach_profile_store(&mut h.session);
prefs_dir::attach_plugin_store(&mut h.session);
prefs_dir::attach_pdf_preset_store(&mut h.session);
h
}
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut headless = false;
@@ -198,14 +208,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless {
Box::new(Headless::with_document())
Box::new(with_stores(Headless::with_document()))
} else if let Some(addr) = connect {
// Explicit address: fail loudly if the app isn't there.
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
} else {
match Remote::connect(DEFAULT_ADDR) {
Ok(r) => Box::new(r),
Err(_) => Box::new(Headless::with_document()),
Err(_) => Box::new(with_stores(Headless::with_document())),
}
};
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
@@ -213,8 +223,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
vectorcraft_mcp::logging::install();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
// The server reads and writes from threads of its own, so it takes the handles, not locks held here.
Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
}
fn commands() -> Result<(), String> {
@@ -327,7 +337,7 @@ fn run(args: &[String]) -> Result<(), String> {
}
}
let mut h = Headless::new();
let mut h = with_stores(Headless::new());
let mut out = std::io::stdout().lock();
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
// steps (an export…) still run, unprinted.
+118 -3
View File
@@ -7,10 +7,18 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
/// that folder and creates it again empty.
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name)
}
/// Removes `dir` and everything in it, creates it again empty and returns it.
fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
dir
}
#[test]
@@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() {
/// server starts, and the process then reads and writes only inside its roots.
#[test]
fn mcp_automation_roots() {
let base = tmp("roots");
automation_roots(&tmp("roots"));
}
/// A run that has the process id of an earlier run finds that run's files and links in its folder.
/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing
/// with "File exists".
#[test]
fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
let dir = tmp("earlier-run");
automation_roots(&dir.join("roots"));
automation_roots(&empty_dir(dir).join("roots"));
}
fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
@@ -346,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
assert!(!out.status.success());
}
/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the
/// developer's real profile.
fn config_dir(name: &str) -> std::path::PathBuf {
let dir = tmp(&format!("config-{name}"));
let _ = std::fs::remove_dir_all(&dir);
dir
}
/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`;
/// the result of each step.
fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec<Value> {
let mut cmd = Command::new(BIN);
cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS");
for (id, params) in steps {
cmd.args(["--cmd", id, "--params", &params.to_string()]);
}
let out = cmd.output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()["result"].clone()).collect()
}
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
/// name of the same length).
fn icc_file_named(path: &std::path::Path, name: &str) {
let builtin = "Display P3";
assert_eq!(builtin.len(), name.len());
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec();
let (from, to) = (utf16(builtin), utf16(name));
let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile");
bytes[at..at + to.len()].copy_from_slice(&to);
std::fs::write(path, bytes).unwrap();
}
fn profile_names(settings: &Value) -> Vec<&str> {
settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect()
}
/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in
/// the next run, and gone again once deleted.
#[test]
fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() {
let config = config_dir("restart");
let icc = tmp("studio-p3.icc");
icc_file_named(&icc, "StudioRGB3");
let r = run_in(
&config,
&[
("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})),
("color.loadProfile", json!({"path": icc.to_str().unwrap()})),
],
);
assert_eq!(r[1]["name"], "StudioRGB3");
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
let presets: Vec<&str> =
r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(presets, ["Press"]);
assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer");
assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1]));
// The profile works as a working space, and the file it came from can go.
std::fs::remove_file(&icc).unwrap();
let steps = [
("edit.colorSettings", json!({"rgb": "StudioRGB3"})),
("pdf.preset.delete", json!({"name": "Press"})),
("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})),
("color.removeProfile", json!({"name": "StudioRGB3"})),
];
let r = run_in(&config, &steps);
assert_eq!(r[0]["rgb"], "StudioRGB3");
assert_eq!(r[3]["removed"], "StudioRGB3");
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
assert!(!profile_names(&r[1]).contains(&"StudioRGB3"));
}
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and
/// leave nothing in the settings folder.
#[test]
fn runs_without_preferences_remember_nothing() {
let config = config_dir("noprefs");
let out = Command::new(BIN)
.args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#])
.env("VECTORCRAFT_CONFIG_DIR", &config)
.env("VECTORCRAFT_NO_PREFS", "1")
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(!config.exists(), "nothing written");
let r = run_in(&config, &[("pdf.preset.list", json!({}))]);
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
}
+9 -2
View File
@@ -8,9 +8,16 @@ use serde_json::Value;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
/// removes that folder and creates it again empty.
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
let dir = DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
});
dir.join(name).to_string_lossy().to_string()
}
+212
View File
@@ -0,0 +1,212 @@
//! Linux Wayland: tell winit where the cursor theme is (#2450).
//!
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
//!
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
//!
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
//! any thread starts and before the window opens.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
#[derive(Debug, Default, PartialEq)]
pub struct DesktopCursor {
pub theme: Option<String>,
pub size: Option<u32>,
}
/// The variables to set, given the environment, the host icon folders that exist and the
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
/// stay as they are.
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
return Vec::new();
}
let mut out = Vec::new();
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
}
if !set("XCURSOR_THEME")
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
{
out.push(("XCURSOR_THEME", OsString::from(theme)));
}
if !set("XCURSOR_SIZE")
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
{
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
}
out
}
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
/// The themes `index.theme` lists after `Inherits=`.
fn inherits(index: &str) -> Vec<String> {
index
.lines()
.find_map(|l| l.trim().strip_prefix("Inherits="))
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
.unwrap_or_default()
}
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
/// whether a file is there.
pub fn theme_usable(
var: impl Fn(&str) -> Option<OsString>,
home: Option<&Path>,
read: impl Fn(&Path) -> Option<String>,
exists: impl Fn(&Path) -> bool,
) -> bool {
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
Some(v) => std::env::split_paths(&v).collect(),
None => DEFAULT_ICON_DIRS
.iter()
.filter_map(|d| match d.strip_prefix("~/") {
Some(rest) => home.map(|h| h.join(rest)),
None => Some(PathBuf::from(d)),
})
.collect(),
};
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
let mut todo = vec![first];
let mut seen: Vec<String> = Vec::new();
while let Some(theme) = todo.pop() {
// Bounded: themes inherit in short chains, and a cycle must end.
if seen.contains(&theme) || seen.len() >= 16 {
continue;
}
for dir in &dirs {
let base = dir.join(&theme);
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
return true;
}
if let Some(index) = read(&base.join("index.theme")) {
todo.extend(inherits(&index));
}
}
seen.push(theme);
}
false
}
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
/// sets a pointer, and the canvas must not hide the system one (#2450).
pub fn theme_missing() -> bool {
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
}
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
fn gsettings_value(output: &str) -> &str {
output.trim().trim_matches('\'')
}
#[cfg(target_os = "linux")]
fn desktop_cursor() -> DesktopCursor {
let get = |key: &str| {
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
};
DesktopCursor {
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
}
}
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
/// unset. Call first thing in `main`, while the process is single-threaded.
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub fn apply() {
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
log::debug!("cursor theme: {name}={value:?}");
// SAFETY: called at the top of `main`, before the app starts any thread.
unsafe { std::env::set_var(name, value) };
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
}
fn adwaita() -> DesktopCursor {
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
}
#[test]
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
assert_eq!(
got,
vec![
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
("XCURSOR_THEME", OsString::from("Adwaita")),
("XCURSOR_SIZE", OsString::from("24")),
]
);
}
#[test]
fn settings_the_user_made_and_other_sessions_stay_untouched() {
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
// A theme-less desktop and an absurd size set nothing.
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
}
#[test]
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
// Compared as paths, so the joins match on Windows too, where they use `\`.
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
let read = |p: &Path| {
if p == Path::new("/i/Breeze/index.theme") {
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
} else if p == Path::new("/i/Loop/index.theme") {
Some("Inherits=Loop\n".to_owned())
} else {
None
}
};
let with = |theme: &'static str| {
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
};
assert!(theme_usable(with("Bibata"), None, read, exists));
assert!(theme_usable(with("Breeze"), None, read, exists));
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
assert!(!theme_usable(with("Nope"), None, read, exists));
assert!(!theme_usable(with("Loop"), None, read, exists));
// Unset: Xcursor's default folders and the `default` theme.
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
}
#[test]
fn gsettings_output_is_unquoted() {
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
assert_eq!(gsettings_value("24\n"), "24");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
//! The panel gets a Format menu as its accessory view
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
//! `beginSheetModalForWindow:completionHandler:`
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
//! one. The save command takes the format from the extension, so an extension typed by hand still
//! decides it.
//!
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
//! the menu's action have no safe API; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
const MARGIN: f64 = 12.0;
const GAP: f64 = 8.0;
/// The Format menu's size, in points.
const MENU: NSSize = NSSize::new(260.0, 26.0);
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(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
set_allowed_types(&panel, &self.ivars().filters, index);
let name = panel.nameFieldStringValue().to_string();
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
}
}
);
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if named.len() < 2 { Vec::new() } else { named }
}
/// The menu entry of the suggested name's type, or the first (the request's default).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = fileio::extension(name);
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
}
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
/// writes) is replaced, and other dots stay part of the name (a version number).
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return String::new();
}
let has = fileio::extension(name);
let known =
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
}
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
first.iter().chain(others).copied().collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
#[allow(deprecated)]
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
/// named file types, or `window` has no AppKit window; nothing is shown then.
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
let mtm = MainThreadMarker::new()?;
let filters = menu_formats(&pick.filters);
if filters.is_empty() {
return None;
}
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and winit hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let parent = view.window()?;
let panel = NSSavePanel::savePanel(mtm);
let selected = initial_format(&pick.name, &filters);
set_allowed_types(&panel, &filters, selected);
if let Some(folder) = &pick.folder {
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
}
panel.setExtensionHidden(false);
if !pick.name.is_empty() {
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
}
let accessory = accessory(mtm, &panel, filters, selected);
panel.setAccessoryView(Some(&accessory));
let (tx, rx) = channel();
let answered = panel.clone();
let handler = RcBlock::new(move |response: NSModalResponse| {
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
// The app gone meanwhile has no use for the answer.
let _ = tx.send(paths);
});
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
// until the sheet closes.
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
Some(rx)
}
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.sizeToFit();
let size = label.frame().size;
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
let x = MARGIN + size.width + GAP;
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
for (name, _) in &filters {
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", 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);
}
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
// sheet is open. The selector has the target/action signature NSControl calls it with.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
}
accessory.addSubview(&label);
accessory.addSubview(&popup);
accessory
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
let save = menu_formats(&fileio::save_filters("vectorcraft"));
assert!(save.len() >= 2, "{save:?}");
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
assert_eq!(initial_format(name, FILTERS), index, "{name}");
}
}
#[test]
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
for (name, expected) in [
("図面 1.vectorcraft", "図面 1.svg"),
("a.PDF", "a.svg"),
("art", "art.svg"),
("art.v2", "art.v2.svg"),
("art.svg", "art.svg"),
("old.drawcraft", "old.svg"),
("photo.png", "photo.svg"),
("", ""),
] {
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
}
}
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
for (label, extensions) in &filters {
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
}
}
/// The menu's label is translated in every language with complete menus.
#[test]
fn the_label_is_translated() {
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
}
}
}
+32 -11
View File
@@ -27,6 +27,7 @@ mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
mod logging;
@@ -35,6 +36,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_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
@@ -473,7 +476,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
// The panel is built and the sheet is begun below, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
@@ -485,12 +488,18 @@ 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(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
@@ -517,8 +526,10 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
// 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.
@@ -646,6 +657,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
";
fn main() -> std::process::ExitCode {
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
#[cfg(target_os = "linux")]
cursor_theme::apply();
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control = automation_args::ControlArg::default();
@@ -841,8 +855,15 @@ fn main() -> std::process::ExitCode {
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
let mut session = Session::new();
// The profiles loaded before come back before the preferences name any of them.
prefs_dir::attach_profile_store(&mut session);
prefs_dir::attach_plugin_store(&mut session);
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
// which takes over from the copy the preferences file used to hold.
prefs_dir::attach_pdf_preset_store(&mut app.session);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
@@ -1023,9 +1044,9 @@ mod tests {
.collect()
}
/// Finder offers the app for every file File › Open reads (#295, #354) but Photoshop documents
/// (`UNASSOCIATED_EXTS`), takes over no other app's files, and hands them to the app rather than
/// to AppKit's document machinery.
/// Finder offers the app for every file File › Open reads (#295, #354) but those with an
/// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app
/// rather than to AppKit's document machinery.
#[test]
fn the_macos_bundle_opens_every_readable_format() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
+33
View File
@@ -24,6 +24,39 @@ pub fn install_fonts() {
}
}
/// The folder `name` next to the settings, for what the user keeps: none in a run without
/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing.
fn store_dir(name: &str) -> Option<PathBuf> {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
return None;
}
Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name))
}
/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and
/// keep the ones loaded from now on there. Before anything names a profile.
pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
if let Some(dir) = store_dir("Color Profiles") {
session.attach_profile_store(dir);
}
}
/// Install the plug-ins Install Plug-in kept in the `Plug-ins` folder (#2541), and keep the ones
/// installed from now on there.
pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) {
if let Some(dir) = store_dir("Plug-ins") {
session.attach_plugin_store(dir);
}
}
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
/// now on there. After the preferences were restored: the folder is the one place the presets live.
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
if let Some(dir) = store_dir("PDF Presets") {
session.attach_pdf_preset_store(dir);
}
}
#[cfg(test)]
mod tests {
use super::*;
+61 -2
View File
@@ -43,11 +43,32 @@ mod system {
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())
}
#[cfg(windows)]
pub(crate) fn setup_command(printer: Option<&str>, default_printer: Option<&str>) -> Command {
let target = printer.map(str::trim).filter(|p| !p.is_empty()).or_else(|| default_printer.map(str::trim).filter(|p| !p.is_empty()));
if let Some(name) = target {
let mut cmd = Command::new("rundll32.exe");
cmd.args(["printui.dll,PrintUIEntry", "/e", "/n", name]);
cmd
} else {
let mut cmd = Command::new("explorer");
cmd.arg("ms-settings:printers");
cmd
}
}
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())
let list = printers();
let default = list.iter().find(|p| p.default).or_else(|| list.first()).map(|p| p.name.as_str());
// Only an installed printer's own name reaches the command line: `printer` can come
// from a command or MCP, and PrintUIEntry reads options from its arguments.
let printer = printer.map(str::trim).filter(|p| list.iter().any(|q| q.name == *p));
let mut cmd = setup_command(printer, default);
match cmd.spawn() {
Ok(_) => Ok(()),
Err(_) => Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string()),
}
}
#[cfg(target_os = "macos")]
{
@@ -68,3 +89,41 @@ mod system {
}
}
}
#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))]
mod tests {
use super::system::setup_command;
use std::ffi::OsStr;
#[test]
fn setup_command_uses_printui_for_specified_printer() {
let cmd = setup_command(Some("Office Laser"), None);
assert_eq!(cmd.get_program(), "rundll32.exe");
let args: Vec<&OsStr> = cmd.get_args().collect();
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Office Laser"]);
}
#[test]
fn setup_command_falls_back_to_default_printer() {
let cmd = setup_command(None, Some("Default Proofer"));
assert_eq!(cmd.get_program(), "rundll32.exe");
let args: Vec<&OsStr> = cmd.get_args().collect();
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]);
}
#[test]
fn setup_command_trims_whitespace_and_skips_empty() {
let cmd = setup_command(Some(" "), Some("Default Proofer"));
assert_eq!(cmd.get_program(), "rundll32.exe");
let args: Vec<&OsStr> = cmd.get_args().collect();
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]);
}
#[test]
fn setup_command_falls_back_to_settings_when_no_printer() {
let cmd = setup_command(None, None);
assert_eq!(cmd.get_program(), "explorer");
let args: Vec<&OsStr> = cmd.get_args().collect();
assert_eq!(args, ["ms-settings:printers"]);
}
}
+338 -14
View File
@@ -1,29 +1,184 @@
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
//!
//! In order:
//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the
//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves
//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding
//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding
//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows
//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is
//! created, the settings in the user's profile are copied into it, so a portable zip that gains
//! the marker keeps its users' settings. If the folder can't be written (read-only medium,
//! Program Files), a warning is logged and the platform folder is used. A portable copy never
//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never
//! looks for the marker.
//! 3. The platform convention.
use std::ffi::OsString;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Files in the install folder that switch on portable mode (either one; contents are ignored).
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"];
/// The settings folder created in the install folder in portable mode.
pub const PORTABLE_DATA_DIR: &str = "VectorCraftData";
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir)
}
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
/// `portable` is the portable settings folder, if any: only asked for when no override is set and
/// preferences are in use.
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
}
// A run without preferences reads and writes nothing, so it must not create the portable folder.
if env("VECTORCRAFT_NO_PREFS").is_none()
&& let Some(dir) = portable()
{
// A portable copy is self-contained: DrawCraft's old profile is not read into it.
return (!legacy).then(|| dir.join("ui.json"));
}
platform_dir(os, legacy, &env).map(|p| p.join("ui.json"))
}
/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`).
fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
let base = match os {
match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
}
}
/// The portable settings folder of this copy, looked for once per process (it probes the disk, and
/// the settings path is asked for many times).
fn portable_dir() -> Option<PathBuf> {
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
DIR.get_or_init(|| {
let os = std::env::consts::OS;
let env = |key: &str| std::env::var_os(key);
let install = install_dir(os, &std::env::current_exe().ok()?, &env)?;
portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| {
// Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger
// keeps early records for the log file.
log::warn!("{warning}");
None
})
})
.clone()
}
/// The folder the user sees the app in, where the portable marker is looked for: the executable's;
/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a
/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when
/// `$APPIMAGE` names it (the executable itself runs from a read-only mount).
fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let exe_dir = exe.parent()?;
let dir = match os {
"macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(),
"windows" => exe_dir.to_path_buf(),
_ => {
let appimage = env("APPIMAGE").map(PathBuf::from);
let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty());
beside.unwrap_or(exe_dir).to_path_buf()
}
};
base.map(|p| p.join("ui.json"))
Some(dir)
}
/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`.
fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?;
let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?;
bundle.extension().filter(|ext| *ext == "app").and(bundle.parent())
}
/// `<install_dir>/VectorCraftData` when a portable marker sits in `install_dir`, created if missing;
/// `None` without a marker, and the warning to log when there is one but that folder can't be
/// written. The first time the folder is created, the settings in `previous` (the user's profile
/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its
/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it
/// is, and a later start finds the folder and copies nothing.
fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result<Option<PathBuf>, String> {
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) };
let data = install_dir.join(PORTABLE_DATA_DIR);
let first_start = !data.exists();
if let Err(e) = ensure_writable(&data) {
return Err(format!(
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead",
marker.display(),
data.display()
));
}
if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) {
match copy_dir(previous, &data) {
Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()),
Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()),
}
}
Ok(Some(data))
}
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
// A leftover probe is harmless; don't give up portable mode over it.
let _ = std::fs::remove_file(&probe);
Ok(())
}
/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be
/// copied; the error says how many were skipped and why the first one was.
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
let mut skipped = 0usize;
let mut first: Option<String> = None;
copy_dir_into(from, to, &mut |why| {
skipped += 1;
first.get_or_insert(why);
});
match first {
None => Ok(()),
Some(why) => Err(format!("{skipped} skipped, the first because {why}")),
}
}
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
let entries = match std::fs::read_dir(from) {
Ok(entries) => entries,
Err(e) => return skip(format!("{}: {e}", from.display())),
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
skip(format!("{}: {e}", from.display()));
continue;
}
};
let (src, dst) = (entry.path(), to.join(entry.file_name()));
// A symbolic link is copied as what it points to, or skipped when that is a folder.
let copied = match entry.file_type() {
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
Err(e) => Err(e),
};
if let Err(e) = copied {
skip(format!("{}: {e}", src.display()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn environment(key: &str) -> Option<OsString> {
match key {
@@ -34,33 +189,202 @@ mod tests {
}
}
/// No portable marker in the install folder.
fn installed() -> Option<PathBuf> {
None
}
#[test]
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
for os in ["macos", "windows", "linux"] {
let env = |key: &str| {
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
};
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env), None);
assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env, installed), None);
}
}
#[test]
fn empty_override_does_not_fall_back_to_user_files() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
assert_eq!(prefs_path_with("macos", false, env), None);
assert_eq!(prefs_path_with("macos", true, env), None);
assert_eq!(prefs_path_with("macos", false, env, installed), None);
assert_eq!(prefs_path_with("macos", true, env, installed), None);
}
#[test]
fn unset_override_keeps_platform_paths_and_legacy_migration() {
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed),
Some("/user/.config/drawcraft/ui.json".into())
);
}
#[test]
fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() {
let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData"));
for os in ["macos", "windows", "linux"] {
assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}");
// A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped.
assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}");
}
}
#[test]
fn the_config_dir_override_wins_over_portable_mode_without_probing_it() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) };
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") };
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with("windows", true, env, portable), None);
}
#[test]
fn a_run_without_preferences_never_looks_for_the_portable_folder() {
let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) };
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") };
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into()));
}
#[test]
fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() {
let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft");
assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into()));
// A bare executable (the CLI zip) and other platforms: the executable's own folder.
assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into()));
assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
}
#[test]
fn an_appimage_looks_for_the_marker_beside_the_appimage_file() {
let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft");
let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) };
assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into()));
assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into()));
let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) };
assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into()));
// The variable means nothing on other platforms.
assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
}
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn either_marker_beside_the_executable_makes_a_writable_data_folder() {
for marker in PORTABLE_MARKERS {
let exe = temp("portable");
std::fs::write(exe.join(marker), b"").unwrap();
let dir = portable_dir_in(&exe, None).unwrap().unwrap();
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}");
assert!(dir.is_dir(), "the data folder is created");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
let _ = std::fs::remove_dir_all(&exe);
}
}
#[test]
fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() {
let folder = temp("bundle");
let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft");
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::write(folder.join("portable.txt"), b"").unwrap();
let install = install_dir("macos", &exe, &environment).unwrap();
assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR))));
assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle");
// A marker dropped inside the bundle is not looked at.
std::fs::remove_file(folder.join("portable.txt")).unwrap();
std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap();
let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR));
assert_eq!(portable_dir_in(&install, None), Ok(None));
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn without_a_marker_file_there_is_no_portable_folder() {
let exe = temp("installed");
assert_eq!(portable_dir_in(&exe, None), Ok(None));
// A folder named like the marker is not a marker.
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
assert_eq!(portable_dir_in(&exe, None), Ok(None));
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker");
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn an_unwritable_portable_folder_is_reported_instead_of_used() {
let exe = temp("unwritable");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
// A file where the data folder should go: it can't be created, on every platform.
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
let warning = portable_dir_in(&exe, None).unwrap_err();
assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}");
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn the_first_start_copies_the_user_profile_into_the_data_folder_once() {
let exe = temp("upgrade");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
let previous = temp("previous-profile");
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap();
std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap();
std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap();
let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap();
assert_eq!(data, exe.join(PORTABLE_DATA_DIR));
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied");
assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied");
assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is");
assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is");
// A second start finds the folder and copies nothing, whatever changed on either side.
std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap();
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap();
std::fs::write(previous.join("later.txt"), b"").unwrap();
assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone())));
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten");
assert!(!data.join("later.txt").exists(), "nothing is copied after the first start");
let _ = std::fs::remove_dir_all(&exe);
let _ = std::fs::remove_dir_all(&previous);
}
#[test]
fn without_a_user_profile_the_data_folder_starts_empty() {
let exe = temp("fresh");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
let missing = exe.join("no-such-profile");
let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap();
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() {
let from = temp("copy-from");
let to = temp("copy-to");
std::fs::write(from.join("kept.txt"), b"kept").unwrap();
std::fs::create_dir_all(from.join("blocked")).unwrap();
std::fs::write(from.join("blocked/inner.txt"), b"").unwrap();
// A file where the sub-folder should go: that entry can't be copied, the others can.
std::fs::write(to.join("blocked"), b"in the way").unwrap();
let error = copy_dir(&from, &to).unwrap_err();
assert!(error.starts_with("1 skipped"), "{error}");
assert!(error.contains("blocked"), "{error}");
assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept");
let _ = std::fs::remove_dir_all(&from);
let _ = std::fs::remove_dir_all(&to);
}
}
+11
View File
@@ -115,3 +115,14 @@ sync_github_name = false
real_name = "Kamil Kwiatkowski"
display_name = "Kamil Kwiatkowski"
sync_github_name = false
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.theromis]
sync_github_name = true
[people.dennismutuku2005]
sync_github_name = true
+35
View File
@@ -965,6 +965,41 @@ fn drawings_the_export_writes_read_back() {
}
}
#[test]
fn closed_control_point_splines_stay_closed_through_export_and_import() {
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
let cases = [(true, true), (true, false), (false, true), (false, false)];
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
for (i, &(closed, returns)) in cases.iter().enumerate() {
let mut b = kurbo::BezPath::new();
b.move_to((20.0, 20.0));
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 {
b.close_path();
}
let id = doc.alloc_id();
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
}
for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) {
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
let out = crate::export(&doc, &opts).unwrap();
let text = std::str::from_utf8(&out.bytes).unwrap();
assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities");
let back = leaves(&open(&out.bytes, &points()).document);
assert_eq!(back.len(), cases.len(), "{version:?}");
for (n, &(closed, returns)) in back.iter().zip(&cases) {
let path = n.path_data().unwrap();
assert_eq!(path.subpaths.len(), 1, "{version:?}");
assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}");
}
}
}
#[test]
fn info_lists_layers_layouts_and_version() {
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
+24 -7
View File
@@ -171,9 +171,25 @@ impl GenericCmyk {
xyz_to_lab(self.to_xyz(cmyk))
}
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
/// With `bpc` (media-relative only) the press black displays as black, undoing the
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
/// darkest rich black shows as black again.
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
if absolute {
return self.to_xyz(cmyk);
}
let x = self.to_xyz_rel(cmyk);
if !bpc {
return x;
}
let bp = self.black_rel;
[0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0))
}
/// Display sRGB, see [`Self::display_xyz`].
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
}
/// Lab of the darkest printable black (media-relative).
@@ -425,11 +441,12 @@ impl GenericCmyk {
self.from_lab(srgb_to_lab(rgb), intent, bpc)
}
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
pub fn gamut_error(&self, src: Lab) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
/// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays
/// (with `bpc`, the press black showing as black).
pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc);
let cmyk = self.separate(target);
delta_e2000(self.to_lab_rel(cmyk), target)
delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src)
}
}
+35 -10
View File
@@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
Ok(info)
}
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
/// as long as the number stays the same.
/// Forget a profile [`register_icc`] registered (by its name); `false` when there is none of that
/// name (the built-in profiles can't be removed). Settings that name it no longer resolve, so a
/// caller checks [`active_settings`] first.
pub fn unregister_icc(name: &str) -> bool {
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
let before = u.len();
u.retain(|x| x.name != name);
let removed = u.len() != before;
drop(u);
if removed {
clear_proof_cache();
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
}
removed
}
/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`]
/// removes one: conversions through a profile looked up by name, such as those of a
/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same.
pub fn profiles_generation() -> u64 {
PROFILES_GENERATION.load(Ordering::Relaxed)
}
@@ -346,10 +362,10 @@ impl Cms {
}
}
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
match space {
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc),
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
CmykSpace::Icc(p) => {
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
@@ -376,7 +392,7 @@ impl Cms {
{
return v;
}
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
}
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
@@ -408,6 +424,15 @@ impl Cms {
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
}
}
/// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in
/// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows
/// as black). Unclipped, for interpolating tables.
pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab {
match &self.cmyk {
CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)),
_ => self.cmyk_to_lab(cmyk),
}
}
/// Lab → working CMYK with `intent`.
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
match &self.cmyk {
@@ -429,7 +454,7 @@ impl Cms {
/// CIE Lab (D50) of any colour.
pub fn lab(&self, c: &Color) -> Lab {
match *c {
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]),
Color::Lab { l, a, b } => Lab::new(l, a, b),
_ => lab::srgb_to_lab(self.display_rgb(c)),
}
@@ -490,7 +515,7 @@ impl Cms {
Color::Rgb { .. } | Color::Lab { .. } => {
let src = self.lab(c);
match &self.cmyk {
CmykSpace::Generic(g) => g.gamut_error(src),
CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc),
CmykSpace::Device => 0.0,
CmykSpace::Icc(_) => {
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
@@ -523,7 +548,7 @@ impl Cms {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => {
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
}
Err(_) => srgb,
},
@@ -534,7 +559,7 @@ impl Cms {
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
match &proof.target {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
},
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
+14
View File
@@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() {
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
}
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
#[test]
fn generic_cmyk_black_round_trips_with_bpc() {
let c = cms();
let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8);
assert_eq!(back, [0, 0, 0], "{cmyk:?}");
// The paper still shows as white, and without compensation the press black stays a dark grey.
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false);
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
}
#[test]
fn lab_colours_round_trip_through_the_cms() {
let c = cms_no_bpc();
+2 -1
View File
@@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() {
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
let p = IccProfile::from_bytes(None, &bytes).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
// The encoded profile converts without black-point compensation, so compare without it.
let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap();
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
let want = cms.cmyk_to_srgb(cmyk, false);
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
+21 -2
View File
@@ -181,6 +181,10 @@ impl Color {
_ => {}
}
let h = s.strip_prefix('#').unwrap_or(s);
// `from_str_radix` accepts a leading `+`, so check the digits first.
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
match h.len() {
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
@@ -383,7 +387,9 @@ impl Paint {
}
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
/// left without a link gets tint 1. Returns whether anything changed.
/// left without a link gets tint 1. A freeform gradient's points, which it draws from, go
/// through `f` too, as unlinked colours (a link `f` gives them is dropped). Returns whether
/// anything changed.
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let changed = f(c, link, tint);
@@ -394,7 +400,12 @@ impl Paint {
};
match self {
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch),
Paint::Gradient(g) => {
let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch);
let points =
g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()).fold(false, |ch, p| one(&mut p.color, &mut None, &mut 1.0) | ch);
stops | points
}
_ => false,
}
}
@@ -412,6 +423,14 @@ mod tests {
assert_eq!(Color::from_hex("zz"), None);
}
#[test]
fn hex_rejects_sign_characters() {
for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] {
assert_eq!(Color::from_hex(bad), None, "{bad}");
}
assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233"));
}
#[test]
fn hsb_roundtrip() {
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
+142
View File
@@ -0,0 +1,142 @@
//! Cooperative cancellation and progress for long-running work (exports, flattening, baking).
//!
//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`)
//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the
//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where
//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The
//! token is tied to the thread that entered the scope, so concurrent sessions on other threads
//! (and tests running in parallel) never see each other's cancellation, and code outside any
//! scope is never cancelled.
//!
//! Work that stops early leaves nothing behind: callers drop whatever was half built and report
//! the error, so a cancelled command changes nothing.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// A flag one thread raises and another polls. Cloning shares the flag.
#[derive(Clone, Debug, Default)]
pub struct CancelToken(Arc<AtomicBool>);
impl CancelToken {
pub fn new() -> Self {
Self::default()
}
/// Ask for the work holding this token to stop.
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// The error of work that stopped because it was cancelled.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Cancelled;
impl std::fmt::Display for Cancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("cancelled")
}
}
impl std::error::Error for Cancelled {}
/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known.
pub type ProgressSink = Rc<dyn Fn(u64, u64, &str)>;
struct Scope {
token: CancelToken,
sink: Option<ProgressSink>,
}
thread_local! {
static CURRENT: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// Puts the previous scope back, also when the work inside unwinds.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
// `try_with`: during thread teardown the slot may be gone; nothing to restore then.
let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous);
}
}
/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread.
pub fn scope<T>(token: &CancelToken, sink: Option<ProgressSink>, f: impl FnOnce() -> T) -> T {
let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink }));
let _restore = Restore(previous);
f()
}
/// Has the work running on this thread been cancelled? Always false outside a [`scope`].
pub fn is_cancelled() -> bool {
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled()))
}
/// Is this thread running inside a [`scope`], so that a cancel can reach it?
pub fn is_cancellable() -> bool {
CURRENT.with(|c| c.borrow().is_some())
}
/// `Err(Cancelled)` when the work running on this thread has been cancelled.
pub fn check() -> Result<(), Cancelled> {
if is_cancelled() { Err(Cancelled) } else { Ok(()) }
}
/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`]
/// that has a sink.
pub fn progress(done: u64, total: u64, what: &str) {
// The sink runs outside the borrow, so it may itself call back into this module.
let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone()));
if let Some(sink) = sink {
sink(done, total, what);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_work_inside_the_scope_is_cancelled() {
let t = CancelToken::new();
assert_eq!(check(), Ok(()));
scope(&t, None, || {
assert_eq!(check(), Ok(()));
t.cancel();
assert_eq!(check(), Err(Cancelled));
// A nested scope with its own token shadows, then restores.
scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(())));
assert!(is_cancelled());
});
assert!(!is_cancelled(), "the scope ended");
}
#[test]
fn progress_reaches_the_sink_and_the_scope_survives_a_panic() {
let seen = Rc::new(RefCell::new(vec![]));
let sink: ProgressSink = {
let seen = seen.clone();
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
};
let t = CancelToken::new();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
scope(&t, Some(sink), || {
progress(1, 3, "page");
panic!("boom");
})
}));
assert!(r.is_err());
progress(2, 3, "after");
assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]);
}
}
+264 -34
View File
@@ -2,10 +2,12 @@
//!
//! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's
//! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK
//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the
//! latter, Deflate-compressed. Every other image decodes to RGB(A) only.
//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink).
//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image
//! decodes to RGB(A) only.
use std::io::Cursor;
use std::sync::Arc;
use zune_jpeg::zune_core::bytestream::ZCursor;
use zune_jpeg::zune_core::colorspace::ColorSpace;
@@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool {
bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*")
}
/// The colour type of TIFF `bytes` and its decoder.
/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than
/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane
/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2)
/// has a plane for each channel; other files have one. The decoder multiplies these counts without
/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel
/// wide, claims at least 2^32 samples.
/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images
/// and for bytes that aren't a TIFF.
pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
let le = bytes.starts_with(b"II*\0");
if !le && !bytes.starts_with(b"MM\0*") {
return None;
}
let u16_at = |at: usize| -> Option<u16> {
let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
};
let u32_at = |at: usize| -> Option<u32> {
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
};
let ifd = usize::try_from(u32_at(4)?).ok()?;
// (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its
// single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points)
let entry = |i: usize| -> Option<(u16, Option<u32>)> {
let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?;
let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?);
let value = match (ty, count) {
(1, 1) => bytes.get(at).map(|&b| u32::from(b)),
(3, 1) => u16_at(at).map(u32::from),
(4 | 13, 1) => u32_at(at),
(16 | 18, 1) => {
let at = usize::try_from(u32_at(at)?).ok()?;
let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) };
u32::try_from(wide).ok()
}
_ => None,
};
Some((u16_at(e)?, value))
};
let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false);
let (mut rows, mut tile_w, mut tile_h) = (None::<u32>, None::<u32>, None::<u32>);
let least = |was: Option<u32>, v: u32| Some(was.map_or(v, |w| w.min(v)));
for i in 0..usize::from(u16_at(ifd)?) {
let Some((tag, value)) = entry(i) else { break };
let Some(v) = value else { continue };
match tag {
256 => width = width.max(v),
257 => height = height.max(v),
277 => planes = planes.max(v),
278 => rows = least(rows, v),
284 => planar |= v == 2,
322 => tile_w = least(tile_w, v),
323 => tile_h = least(tile_h, v),
_ => {}
}
}
let planes = if planar { u128::from(planes) } else { 1 };
let tiles = match (tile_w, tile_h) {
(Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128,
_ => false,
};
let rows = rows.unwrap_or(height);
let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX);
(tiles || strips).then_some((width, height))
}
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
if !is_tiff(bytes) {
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
return None;
}
let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?;
Some((d.colortype().ok()?, d))
}
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
}
/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color
/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file
/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to
/// that many samples, which includes images whose planes together need more than the decoder's
/// limit (256 MB of samples).
fn read_samples(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>, n: usize, per: usize) -> Option<Vec<u8>> {
let mut buf = tiff::decoder::DecodingResult::U8(Vec::new());
let layout = d.read_image_to_buffer(&mut buf).ok()?;
let data = match buf {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if Some(data.len()) != n.checked_mul(per) {
return None;
}
if layout.planes <= 1 {
return Some(data);
}
if layout.planes != per {
return None;
}
let planes: Vec<&[u8]> = data.chunks_exact(n).collect();
Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect())
}
impl ImageBlob {
/// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written.
pub fn cmyk_tiff(inks: &Inks) -> Option<Self> {
use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
Self::cmyk_tiff_at(inks, None)
}
/// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per
/// inch) when it is given and both are finite and above 0.
pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option<Self> {
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
use tiff::tags::{ResolutionUnit, Tag};
let mut out = Cursor::new(Vec::new());
TiffEncoder::new(&mut out)
.ok()?
.with_compression(Compression::Deflate(DeflateLevel::Balanced))
.write_image::<CMYK8>(inks.width, inks.height, &inks.data)
.ok()?;
let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
let mut img = enc.new_image::<CMYK8>(inks.width, inks.height).ok()?;
if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
// Ten-thousandths of a pixel per inch.
let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
}
img.write_data(&inks.data).ok()?;
Some(Self::new("image/tiff", out.into_inner()))
}
@@ -88,6 +201,53 @@ impl ImageBlob {
let bytes = self.bytes.as_slice();
if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }
}
/// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image
/// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered
/// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images
/// [`Self::cmyk`] doesn't read. The inks of the last CMYK image are kept for the next sample
/// (the Eyedropper samples one image click after click), as [`Self::sample_object`] keeps its
/// pixels.
pub fn sample_cmyk_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> {
thread_local! {
static LAST: std::cell::RefCell<Option<(String, Arc<Inks>)>> = const { std::cell::RefCell::new(None) };
}
if !self.is_cmyk() {
return None;
}
let key = self.content_key();
let inks = LAST.with_borrow_mut(|last| match last {
Some((k, inks)) if *k == key => Some(inks.clone()),
_ => {
let inks = Arc::new(self.cmyk()?);
*last = Some((key, inks.clone()));
Some(inks)
}
})?;
let k = |n: u32, of: u32| n as f64 / of.max(1) as f64;
sample_inks(&inks, x * k(inks.width, object.0), y * k(inks.height, object.1), size)
}
}
/// [`ImageBlob::sample_cmyk_object`] of `inks` at (`x`, `y`) in their own pixel space.
fn sample_inks(inks: &Inks, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
let (w, h) = (i64::from(inks.width), i64::from(inks.height));
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
return None;
}
let r = i64::from(size.max(1)) / 2;
let (mut sum, mut n) = ([0u64; 4], 0u64);
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
let i = usize::try_from((py * w + px) * 4).ok()?;
let p = inks.data.get(i..i + 4)?;
sum.iter_mut().zip(p).for_each(|(s, v)| *s += u64::from(*v));
n += 1;
}
}
let c = |v: u64| ((v + n / 2) / n) as u8;
Some(sum.map(c))
}
/// The ink amounts of a four-component JPEG (its samples inverted back; YCCK converted to CMYK
@@ -119,34 +279,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
Inks::new(w, h, data)
}
/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits).
/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane
/// per ink.
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
// Planar samples (one plane per ink) would read as interleaved.
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() {
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
return None;
}
let data = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
Inks::new(w, h, data)
let n = usize::try_from(pixels(w, h)?).ok()?;
Inks::new(w, h, read_samples(&mut d, n, 4)?)
}
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
/// ink, which the image decoder doesn't read, their ink amounts written again by
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
let inks = tiff_inks(bytes)?;
let (_, mut d) = tiff(bytes)?;
if !planar(&mut d) {
return Some(bytes.to_vec());
}
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
}
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
/// stored together or one plane per channel. `None` for other images and ones larger than
/// [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
// 0: white is zero, 1: black is zero (the two grayscales).
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
@@ -159,18 +327,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
}
_ => return None,
};
if planar {
return None;
}
let n = usize::try_from(pixels(w, h)?).ok()?;
let data: Vec<u8> = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if Some(data.len()) != n.checked_mul(samples) {
return None;
}
let data = read_samples(&mut d, n, samples)?;
let mut out = Vec::with_capacity(n * 4);
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
@@ -246,6 +404,64 @@ mod tests {
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
/// times 2 planes.
#[test]
fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() {
let header = |planar: u32| {
let mut f = b"II*\0\x08\0\0\0".to_vec();
let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)];
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
for (tag, v) in entries {
f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat());
f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat());
}
f.extend([0; 4]);
f
};
assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX)));
assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits");
assert!(tiff(&header(2)).is_none(), "not decoded");
}
/// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2
/// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and
/// reads a LONG8 value from where the entry points.
#[test]
fn tiffs_with_more_strips_than_can_be_counted_are_oversized() {
// Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory).
let header = |entries: &[(u16, u16, u32)]| {
let after = 8 + 2 + 12 * entries.len() + 4;
let mut f = b"II*\0\x08\0\0\0".to_vec();
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
let mut wide = vec![];
for &(tag, ty, v) in entries {
f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat());
f.extend(1u32.to_le_bytes());
if ty == 16 {
f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes());
wide.extend(u64::from(v).to_le_bytes());
} else {
f.extend(v.to_le_bytes());
}
}
f.extend([0; 4]);
f.extend(wide);
f
};
let tall = 1u32 << 31 | 1;
let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall)));
let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat();
assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets");
let mut long8 = strips;
long8[1] = (257, 16, tall);
assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8");
for h in [header(&tile_sizes), header(&long8)] {
assert!(tiff(&h).is_none(), "not decoded");
}
}
fn inks() -> Inks {
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
}
@@ -261,6 +477,20 @@ mod tests {
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
}
#[test]
fn a_cmyk_image_samples_its_ink_amounts() {
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
assert_eq!(b.sample_cmyk_object(0.5, 0.5, (3, 2), 1), Some([0, 40, 100, 0]));
// An object of 6 × 4 pixels showing these 3 × 2: (5, 3) is the last pixel.
assert_eq!(b.sample_cmyk_object(5.0, 3.0, (6, 4), 1), Some([12, 34, 56, 78]));
// 3 × 3 around (1, 0), clipped to the image: all six pixels averaged.
assert_eq!(b.sample_cmyk_object(1.5, 0.5, (3, 2), 3), Some([45, 55, 69, 56]));
assert_eq!(b.sample_cmyk_object(3.5, 0.5, (3, 2), 1), None, "outside");
let mut png = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(ImageBlob::png(png).sample_cmyk_object(0.5, 0.5, (2, 2), 1), None, "an RGB image has no inks");
}
#[test]
fn a_cmyk_jpeg_reads_back_as_ink_amounts() {
// A flat CMYK JPEG: Adobe's inverted samples with its marker.
+11 -2
View File
@@ -5,7 +5,7 @@
use std::borrow::Cow;
use std::collections::BTreeSet;
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, PathData, Rect};
@@ -23,6 +23,8 @@ pub struct LiveCorners<'a> {
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
pub corners: Vec<Corner>,
radii: Vec<f64>,
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
drawn: Vec<f64>,
kinds: Vec<CornerKind>,
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
sources: Vec<Vec<usize>>,
@@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> {
})
.collect()
};
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
let corners = path_corners(&base);
let drawn = fitted_radii(&corners, &radii);
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
}
/// The Live Corners of a path object.
@@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> {
self.radii.get(index).copied().unwrap_or(0.0)
}
/// The radius corner `index` is drawn with, in document units: its own, no larger than its
/// neighbours leave room for (0 for an anchor that is no corner).
pub fn drawn_radius(&self, index: usize) -> f64 {
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0)
}
/// The kind corner `index` is cut with.
pub fn kind(&self, index: usize) -> CornerKind {
self.kinds.get(index).copied().unwrap_or_default()
+69 -7
View File
@@ -196,6 +196,57 @@ macro_rules! ids {
ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" });
ids!(PieSort { None => "none", All => "all", First => "first" });
/// Object › Graph › Column › Column Type: how a column design fills a column or bar. Vertically Scaled stretches it
/// along the value axis to the column's length at the column's width; Uniformly Scaled scales it both ways to that
/// length, centered on the column; Repeating stacks copies that each stand for [`ColumnDesign::each`]; Sliding scales the
/// part above its guide line (in a bar, right of it) in proportion to the column's width and stretches the rest.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ColumnType {
#[default]
Vertical,
Uniform,
Repeating,
Sliding,
}
/// Object › Graph › Column › For Fractions: a repeating design's last copy, for the part of a value less than one
/// design, is cut off (Chop) or scaled down along the value axis (Scale).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Fractions {
#[default]
Chop,
Scale,
}
ids!(ColumnType { Vertical => "vertical", Uniform => "uniform", Repeating => "repeating", Sliding => "sliding" });
ids!(Fractions { Chop => "chop", Scale => "scale" });
/// Object › Graph › Column: the graph design a series' columns or bars are drawn with, and how it fills them.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ColumnDesign {
pub design: String,
#[serde(skip_serializing_if = "ColumnType::is_default")]
pub column_type: ColumnType,
/// Repeating: the value one copy of the design stands for (Each Design Represents).
pub each: f64,
#[serde(skip_serializing_if = "Fractions::is_default")]
pub fractions: Fractions,
}
impl ColumnDesign {
/// Each Design Represents for a new column design.
pub const EACH: f64 = 10.0;
}
impl Default for ColumnDesign {
fn default() -> Self {
Self { design: String::new(), column_type: ColumnType::Vertical, each: Self::EACH, fractions: Fractions::Chop }
}
}
/// 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")]
@@ -345,6 +396,11 @@ pub struct GraphSpec {
/// 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>>,
/// Object › Graph › Column: the graph design each series' columns or bars (and legend swatch) are drawn with, by
/// series index, for column, stacked column, bar and stacked bar series; `None` and missing entries draw plain
/// columns.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub series_columns: Vec<Option<ColumnDesign>>,
/// 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")]
@@ -430,17 +486,22 @@ impl GraphSpec {
let cells = self.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
let field = |c: &str, always: bool| {
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
format!("\"{}\"", c.replace('"', "\"\""))
} else {
c.to_string()
}
};
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !self.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
out.push_str(&format!("\"{c}\""));
} else {
out.push_str(c);
}
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
out.push(',');
}
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
@@ -474,6 +535,7 @@ impl Default for GraphSpec {
right_axis_min: None,
right_axis_max: None,
series_markers: vec![],
series_columns: vec![],
label_styles: LabelStyles::default(),
first_row_in_front: false,
first_column_in_front: false,
+308 -13
View File
@@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
tops
}
/// The most objects [`locked_objects_at`] lists.
pub const MAX_LOCKED_AT: usize = 20;
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
/// (`opt.scope`) only what is inside the isolated group or object.
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
let symbols = doc.symbol_boxes();
let mut out = Vec::new();
if let Some(scope) = opt.scope
&& let Some(isolated) = doc.node(scope)
{
if isolated.is_container() {
locked_children(isolated, p, opt, &symbols, &mut out);
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
out.push(isolated.id);
}
return out;
}
for layer in doc.layers.iter().rev() {
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
locked_children(layer, p, opt, &symbols, &mut out);
}
out
}
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
let Some(children) = parent.children() else { return };
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return;
}
for i in hit_order(parent, children, p) {
if out.len() >= MAX_LOCKED_AT {
return;
}
let Some(c) = children.get(i) else { continue };
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
continue;
}
if c.locked {
if locked_hit(c, p, opt, symbols) {
out.push(c.id);
}
continue;
}
match &c.kind {
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
NodeKind::Layer { .. }
| NodeKind::Group { .. }
| NodeKind::Intertwine { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
_ => {}
}
}
}
/// Would a click at `p` hit the locked object `n` if it were not locked?
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
match &n.kind {
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
_ => hit_leaf(n, p, opt, symbols).is_some(),
}
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
@@ -220,11 +294,17 @@ fn edits_contents(n: &Node) -> bool {
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
/// glyph outlines need the font engine, above this crate.
fn clip_contains(clip: &Node, p: Point) -> bool {
clip_regions(clip).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
}
/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in
/// [`clip_contains`].
fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> {
let frame = |n: &Node| {
let b = n.geometric_bounds()?;
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
};
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
clip.clip_shapes(Some(&frame))
}
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
@@ -390,15 +470,39 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
}
}
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
let NodeKind::Compound { children, rule } = &n.kind else { return None };
let parts: Vec<_> = children.iter().rev().filter_map(|c| c.path_data().map(|pd| (c.id, pd.to_bezpath(), pd.bounds()))).collect();
if let Some((id, ..)) = parts.iter().find(|(_, bp, _)| stroke_contains(bp, 0.0, tol, p)) {
return Some(*id);
}
// The innermost by the size of its box.
let size = |b: &Option<Rect>| b.map_or(f64::INFINITY, |b| b.area());
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
/// instances are measured by their art ([`Node::geometric_bounds_with`]).
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
/// path clips to, and then only within that region's bounds.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => match s.children() {
Some(c) => c.iter().collect(),
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
Some(s) => match (s.children(), marquee_in(s, r)) {
(Some(c), Some(r)) => c.iter().map(|c| (c, r)).collect(),
(Some(_), None) => vec![],
// A single object isolated: only it.
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
(None, _) => doc
.parent_of(s.id)
.and_then(|p| doc.node(p))
.and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
.into_iter()
.flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
.collect(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
@@ -406,18 +510,21 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
.layers
.iter()
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
.flat_map(|l| layer_objects(l))
.flat_map(|l| layer_objects(l, r))
.collect(),
};
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
/// The objects of layer `l`, each with the part of `r` that the clipping masks of `l` and of
/// the sublayers it is in leave.
fn layer_objects(l: &Node, r: Rect) -> Vec<(&std::sync::Arc<Node>, Rect)> {
let mut out = vec![];
let Some(r) = marquee_in(l, r) else { return out };
for c in l.children().into_iter().flatten() {
if c.is_layer() {
if c.visible && !c.locked && !c.is_template() {
out.extend(layer_objects(c));
out.extend(layer_objects(c, r));
}
} else {
out.push(c);
out.push((c, r));
}
}
out
@@ -425,9 +532,10 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
children.iter().any(|c| c.visible && touches(c, s, r))
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
marquee_in(n, r).is_some_and(|r| children.iter().any(|c| c.visible && touches(c, s, r)))
}
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r)),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
@@ -439,6 +547,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
let Some(r) = marquee_in(n, r) else { return };
for c in children {
if c.visible && !c.locked {
collect_leaves(c, s, r, out);
@@ -453,7 +562,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
}
let symbols = doc.symbol_boxes();
for t in tops {
for (t, r) in tops {
if !t.visible || t.locked {
continue;
}
@@ -466,6 +575,36 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
out
}
/// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a
/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None
/// when the marquee misses it); otherwise it is all of `r`.
fn marquee_in(parent: &Node, r: Rect) -> Option<Rect> {
match parent.children().and_then(|c| c.first()) {
Some(clip) if parent.clips() => marquee_in_clip(clip, r),
_ => Some(r),
}
}
/// The part of a marquee `r` in which it can touch the art that `clip` clips (the clipping path of
/// a clip group or of a layer with a clipping mask): `r` cut to the bounds of the regions `clip`
/// clips to ([`clip_regions`]), or `None` when `r` meets none of those regions. Within those
/// bounds, art outside a region that is not a rectangle still counts.
fn marquee_in_clip(clip: &Node, r: Rect) -> Option<Rect> {
use vectorcraft_geom::kurbo::Shape;
let regions = clip_regions(clip);
// The edge of a region is its outline with each open subpath closed, as `fill_contains` fills it.
let meets = |(bp, rule): &(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)| {
let mut edge = vectorcraft_geom::PathData::from_bezpath(bp);
edge.subpaths.iter_mut().for_each(|sp| sp.closed = true);
fill_contains(bp, *rule, r.center()) || vectorcraft_geom::hit::intersects_rect(&edge, r)
};
if !regions.iter().any(meets) {
return None;
}
let bounds = regions.iter().map(|(bp, _)| bp.bounding_box()).reduce(|a, b| a.union(b))?;
Some(r.intersect(bounds))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -539,6 +678,99 @@ mod tests {
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
}
/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and
/// the art, in that order.
fn half_clipped(d: &mut Document) -> (Node, Node) {
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art());
(mask, art)
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
/// Group Selection's leaves and in isolation mode.
#[test]
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id());
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
// This marquee crosses only the hidden art's top and right edges.
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
assert!(marquee(&d, hidden, None, false).is_empty());
assert!(marquee(&d, hidden, None, true).is_empty());
assert!(marquee(&d, hidden, Some(g), false).is_empty());
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
// This marquee crosses the shown half's top edge and goes on into the hidden half.
let shown = Rect::new(50.0, -10.0, 160.0, 10.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert!(marquee(&d, shown, None, true).contains(&art_id));
assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art");
}
/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the
/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a
/// straight line, and a marquee across that line takes the art it reaches inside.
#[test]
fn a_marquee_must_meet_the_clipping_path_itself() {
// A clip group of a mask through `pts` (closed or open) over the art `art`.
let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let mut mask = vectorcraft_geom::BezPath::new();
mask.move_to(pts[0]);
for &p in &pts[1..] {
mask.line_to(p);
}
if closed {
mask.close_path();
}
let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art());
let g = d.alloc_id();
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
(d, g)
};
let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty());
assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point");
// The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it.
let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0));
assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]);
}
/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the
/// mask shows them, as in a clip group.
#[test]
fn a_marquee_leaves_art_a_layer_clips_away() {
for sublayer in [false, true] {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let beside = d.alloc_id();
d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
let masked = d.alloc_id();
let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2));
layer.set_clips(true);
d.insert(sublayer.then_some(l), 9, layer).unwrap();
let (mask, art) = half_clipped(&mut d);
let (mask_id, art_id) = (mask.id, art.id);
d.insert(Some(masked), 0, mask).unwrap();
d.insert(Some(masked), 1, art).unwrap();
assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}");
}
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
@@ -901,6 +1133,69 @@ world",
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
}
/// Insert `n` locked at the top of `parent`.
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
let id = n.id;
n.locked = true;
d.insert(Some(parent), usize::MAX, n).unwrap();
id
}
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
/// Hidden objects, locked layers and the art outside an isolated group are left out.
#[test]
fn locked_objects_at_lists_the_locked_stack() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let rect =
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
inner.locked = true;
let inner_id = inner.id;
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
g.locked = true;
d.insert(Some(l), usize::MAX, g).unwrap();
let open_group = d.alloc_id();
let mut child = rect(&mut d, 25.0);
child.locked = true;
let child_id = child.id;
let free = rect(&mut d, 25.0);
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
assert_eq!(at(&d, 5.0, None), [a]);
assert!(at(&d, 150.0, None).is_empty());
// Isolated, only the group's own objects are looked at.
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
d.node_mut(b).unwrap().visible = false;
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
d.node_mut(l).unwrap().locked = true;
assert!(at(&d, 30.0, None).is_empty());
d.node_mut(l).unwrap().locked = false;
d.node_mut(l).unwrap().visible = false;
assert!(at(&d, 30.0, None).is_empty());
}
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
#[test]
fn locked_objects_at_stops_at_its_limit() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
.map(|_| {
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
insert_locked(&mut d, l, n)
})
.collect();
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
}
#[test]
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
let mut d = Document::new(200.0, 200.0);
+212 -8
View File
@@ -8,6 +8,7 @@
pub mod appearance;
pub mod assets;
pub mod blend;
pub mod cancel;
pub mod clipnest;
pub mod cmyk;
pub mod corners;
@@ -20,12 +21,17 @@ pub mod inks;
mod inline;
pub mod intertwine;
pub mod shared;
mod symbol_export_type;
mod symbol_nine_slice;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
pub mod live_paint;
pub mod marks;
mod mem;
pub mod metadata;
@@ -87,17 +93,18 @@ pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use depth::MAX_DEPTH;
pub use graph::{
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
ValueAxisSide,
ColumnDesign, ColumnType, Fractions, 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 live_paint::{GapOptions, GapSize};
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::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
@@ -110,9 +117,9 @@ 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, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
TextWrap, VerticalAlign, WrapShape,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
@@ -589,6 +596,23 @@ pub struct ImageBlob {
pub proxy: Option<Arc<Vec<u8>>>,
}
/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a
/// new document, or one origin for the whole document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RulerMode {
#[default]
Artboard,
Global,
}
impl RulerMode {
/// The mode of a document saved before the choice existed: global, as its rulers measured.
pub fn legacy() -> Self {
Self::Global
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub version: u32,
@@ -602,6 +626,15 @@ pub struct Document {
/// 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,
/// View › Rulers › Change to Global / Artboard Rulers: whether the rulers and position fields
/// measure from [`Self::ruler_origin`] (global) or from the active artboard's own origin.
/// Documents saved before the choice existed measured globally and open that way.
#[serde(default = "RulerMode::legacy")]
pub rulers: RulerMode,
/// Artboard rulers' zero point of each artboard (by [`Artboard::id`]), as an offset from its
/// top-left corner; an artboard not listed measures from that corner.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub artboard_ruler_origins: BTreeMap<u32, Vec2>,
#[serde(default)]
pub color_mode: ColorMode,
pub artboards: Vec<Artboard>,
@@ -692,6 +725,10 @@ pub struct Document {
/// save time only, so changing the view never marks the document modified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_view: Option<SavedView>,
/// View → Show Transparency Grid when the document was saved. Written at save time only,
/// like [`Document::last_view`]; older files open with the grid hidden.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub transparency_grid: bool,
/// The layers, sublayers and groups open in the Layers panel when the document was saved; they
/// reopen that way. `None`: the default, only the top-level layers open. Written at save time
/// only, like [`Document::last_view`].
@@ -724,6 +761,10 @@ pub struct Document {
/// export settings are Export for Screens' ([`Document::export_settings`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<ExportAsset>,
/// Window › SVG Interactivity › Link JavaScript Files: the JavaScript files (paths or URLs, as
/// given) SVG export links with `<script>` elements, in order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub svg_scripts: Vec<String>,
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
/// state, never saved.
#[serde(skip)]
@@ -745,6 +786,14 @@ fn yes() -> bool {
true
}
/// Is point `p` on the artboard at `rect`, its edges included, within a millionth of a point? This
/// is the rule [`Document::art_on_artboard`] applies to art flush with an edge (#890), where
/// `Rect::contains` leaves out the right and bottom edges.
pub fn artboard_holds(rect: Rect, p: Point) -> bool {
let r = rect.inflate(1e-6, 1e-6);
r.x0 <= p.x && p.x <= r.x1 && r.y0 <= p.y && p.y <= r.y1
}
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
/// of each listed child in it, in order, with the child's place in the list.
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
@@ -759,6 +808,27 @@ impl Document {
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
}
/// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`]
/// with global rulers; with artboard rulers that artboard's top-left corner moved by its own
/// offset (the first artboard's when `index` is out of range, the global zero point when the
/// document has no artboard or the result is out of the supported range).
pub fn ruler_origin_for(&self, index: usize) -> Point {
if self.rulers == RulerMode::Global {
return self.ruler_origin_or_zero();
}
let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() };
let corner = Point::new(ab.rect.x0, ab.rect.y0);
let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO);
let origin = corner + offset;
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD {
origin
} else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD {
corner
} else {
Point::ZERO
}
}
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
pub fn new(width: f64, height: f64) -> Self {
Self::new_with_mode(width, height, ColorMode::Rgb)
@@ -773,6 +843,8 @@ impl Document {
template: false,
units: Unit::Points,
ruler_origin: Point::ZERO,
rulers: RulerMode::Artboard,
artboard_ruler_origins: BTreeMap::new(),
color_mode: mode,
artboards: vec![Artboard {
id: 1,
@@ -810,6 +882,7 @@ impl Document {
metadata: DocMetadata::default(),
raster_effects: RasterEffectsSettings::default(),
last_view: None,
transparency_grid: false,
layers_open: None,
color_profiles: ColorProfiles::default(),
export_settings: Default::default(),
@@ -818,6 +891,7 @@ impl Document {
slices_clip_to_artboard: true,
print_setup: None,
assets: vec![],
svg_scripts: vec![],
puppet: None,
dimensions: false,
index: Default::default(),
@@ -1290,9 +1364,24 @@ impl Document {
let symbols = self.symbol_boxes();
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
}
/// Artboard index containing point `p` (topmost = last).
/// Artboard index containing point `p` (topmost = last), its edges included
/// ([`Document::artboard_holding`]).
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
self.artboard_holding(p, true)
}
/// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it
/// inside or on its left or top edge, else the first (the last) that holds it by
/// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an
/// edge. On an edge two artboards share, the artboard to the right of it or below it holds the
/// point, except at the end of the edge that lies on that artboard's bottom or right edge, such
/// as the point where both artboards' bottom edges meet; there the first (the last) holds it.
pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option<usize> {
let pick = |on: &dyn Fn(Rect) -> bool| {
let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect));
let mut all = 0..self.artboards.len();
if topmost { all.rfind(on) } else { all.find(on) }
};
pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p)))
}
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
@@ -1578,6 +1667,83 @@ impl Document {
o.insert(name.into(), serde_json::json!(reg.0));
}
}
/// Move the stored registration when a symbol is renamed.
pub fn rename_symbol_registration(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let reg = self.symbol_registration(from);
self.set_symbol_registration(from, SymbolRegistration::CENTER);
self.set_symbol_registration(to, reg);
}
/// Symbol Options › Export Type for `name` (default: Movie Clip).
pub fn symbol_export_type(&self, name: &str) -> SymbolExportType {
self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default()
}
/// Set or clear (when Movie Clip, the default) the export type stored for `name`.
pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) {
if kind.is_default() {
self.clear_symbol_export_type(name);
return;
}
let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({}));
let Some(o) = m.as_object_mut() else { return };
o.insert(name.into(), serde_json::json!(kind.id()));
}
/// Drop any stored export type for `name` (Movie Clip default).
pub fn clear_symbol_export_type(&mut self, name: &str) {
let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return };
let Some(o) = m.as_object_mut() else { return };
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_EXPORT_TYPE);
}
}
/// Move the stored export type when a symbol is renamed.
pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let kind = self.symbol_export_type(from);
self.clear_symbol_export_type(from);
self.set_symbol_export_type(to, kind);
}
/// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default).
pub fn symbol_nine_slice(&self, name: &str) -> Option<SymbolNineSlice> {
self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value)
}
/// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`.
pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option<SymbolNineSlice>) {
let Some(g) = guides else {
let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return };
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_NINE_SLICE);
}
return;
};
let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({}));
if let Some(o) = m.as_object_mut() {
o.insert(name.into(), g.to_value());
}
}
/// Move the stored 9-slice guides when a symbol is renamed.
pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let g = self.symbol_nine_slice(from);
self.set_symbol_nine_slice(from, None);
self.set_symbol_nine_slice(to, g);
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
@@ -1696,6 +1862,44 @@ mod tests {
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
}
#[test]
fn artboard_at_counts_the_right_and_bottom_edges() {
// Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it.
let mut d = Document::new(100.0, 100.0);
for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] {
let mut a = d.artboards[0].clone();
a.rect = rect;
d.artboards.push(a);
}
let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false));
// Each artboard's own right and bottom edges, edges with roundoff, and points beyond them.
assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge");
assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge");
assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner");
assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge");
assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge");
assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge");
assert_eq!(held(&d, 200.001, 50.0), (None, None));
assert_eq!(held(&d, 50.0, 200.001), (None, None));
// On an edge two artboards share, the artboard to the right of it or below it holds the
// point, in either list order, except at the end of the edge that lies on that artboard's
// bottom or right edge.
assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1)));
assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2)));
// The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top
// right corner: the first holds the point, or the last when `topmost`.
assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner");
d.artboards.swap(0, 1);
assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0)));
// Where two artboards overlap, the topmost holds the point, or the first one when not
// `topmost`.
let mut inner = d.artboards[1].clone();
inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0);
d.artboards.push(inner);
assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1)));
assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3");
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
+130
View File
@@ -0,0 +1,130 @@
//! Live Paint groups' Gap Options (Object › Live Paint › Gap Options): whether paint stops at small
//! gaps between the group's paths, how wide a gap it stops at, and the color gaps preview in.
//! A group without options has Gap Detection off.
use serde::{Deserialize, Serialize};
use crate::node::{LAYER_COLORS, Node};
/// Paint Stops At: the widest gap Gap Detection closes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GapSize {
/// Small Gaps: [`GapSize::SMALL`] points.
#[default]
Small,
/// Medium Gaps: [`GapSize::MEDIUM`] points.
Medium,
/// Large Gaps: [`GapSize::LARGE`] points.
Large,
/// Custom: this many points, over 0 up to [`GapSize::MAX`].
Custom(f64),
}
impl GapSize {
pub const SMALL: f64 = 3.0;
pub const MEDIUM: f64 = 6.0;
pub const LARGE: f64 = 12.0;
/// The widest custom gap.
pub const MAX: f64 = 72.0;
/// The preset sizes with their ids, smallest first.
pub const PRESETS: [(Self, &'static str); 3] = [(Self::Small, "small"), (Self::Medium, "medium"), (Self::Large, "large")];
/// The widest gap it closes, in points.
pub fn points(self) -> f64 {
match self {
Self::Small => Self::SMALL,
Self::Medium => Self::MEDIUM,
Self::Large => Self::LARGE,
Self::Custom(pt) => pt,
}
}
/// The id of a preset size (`small`, `medium` or `large`); None for a custom size.
pub fn id(self) -> Option<&'static str> {
Self::PRESETS.iter().find(|(s, _)| *s == self).map(|(_, id)| *id)
}
/// A preset size by its id (any case), or a custom size from a number of points over 0 up to
/// [`GapSize::MAX`].
pub fn parse(v: &serde_json::Value) -> Option<Self> {
match v {
serde_json::Value::String(id) => Self::PRESETS.iter().find(|(_, i)| i.eq_ignore_ascii_case(id.trim())).map(|(s, _)| *s),
v => v.as_f64().filter(|pt| *pt > 0.0 && *pt <= Self::MAX).map(Self::Custom),
}
}
}
/// Gap Preview Color for a group that has none set: the layer color Red.
pub const DEFAULT_GAP_COLOR: [u8; 3] = LAYER_COLORS[1].1;
/// A Live Paint group's Gap Options.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GapOptions {
/// Gap Detection: paint stops at gaps up to `size` wide.
pub detect: bool,
/// Paint Stops At.
pub size: GapSize,
/// Gap Preview Color: the color gaps show in.
pub color: [u8; 3],
}
impl Default for GapOptions {
fn default() -> Self {
Self { detect: false, size: GapSize::Small, color: DEFAULT_GAP_COLOR }
}
}
impl GapOptions {
/// The widest gap paint stops at, in points; None with Gap Detection off.
pub fn max_gap(&self) -> Option<f64> {
self.detect.then(|| self.size.points())
}
}
impl Node {
/// The Gap Options of a Live Paint group (the defaults when it has none set).
pub fn gap_options(&self) -> GapOptions {
self.extras().live_paint_gaps.as_deref().copied().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn sizes_parse_from_ids_and_points() {
for (size, id) in GapSize::PRESETS {
assert_eq!(GapSize::parse(&json!(id)), Some(size));
assert_eq!(GapSize::parse(&json!(id.to_ascii_uppercase())), Some(size));
assert_eq!(size.id(), Some(id));
}
assert_eq!(GapSize::parse(&json!("Medium")).map(GapSize::points), Some(6.0));
assert_eq!(GapSize::parse(&json!("large")).map(GapSize::points), Some(12.0));
assert_eq!(GapSize::parse(&json!(4.5)), Some(GapSize::Custom(4.5)));
assert_eq!(GapSize::Custom(4.5).points(), 4.5);
assert_eq!(GapSize::Custom(4.5).id(), None);
for bad in [json!(0), json!(-1), json!(72.5), json!("tiny"), json!(null), json!(true)] {
assert_eq!(GapSize::parse(&bad), None, "{bad}");
}
}
#[test]
fn options_round_trip_and_default_to_no_detection() {
let mut g = Node::group(crate::NodeId(1), vec![]);
assert_eq!(g.gap_options(), GapOptions::default());
assert_eq!(g.gap_options().max_gap(), None);
let set = GapOptions { detect: true, size: GapSize::Custom(5.0), color: [1, 2, 3] };
g.extras_mut().live_paint_gaps = Some(Box::new(set));
assert_eq!(g.gap_options().max_gap(), Some(5.0));
let text = serde_json::to_string(&g).unwrap();
let back: Node = serde_json::from_str(&text).unwrap();
assert_eq!(back.gap_options(), set);
let bare: GapOptions = serde_json::from_value(json!({"detect": true})).unwrap();
assert_eq!(bare, GapOptions { detect: true, ..GapOptions::default() });
}
}
+1
View File
@@ -250,6 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
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 += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
}
+69
View File
@@ -570,6 +570,8 @@ pub struct NodeExtras {
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
/// filled non-zero) where this object is drawn over the group's other members.
pub intertwine: Option<Box<PathData>>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -587,6 +589,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
perspective: None,
dimension: None,
intertwine: None,
live_paint_gaps: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -720,6 +723,7 @@ impl Serialize for Node {
e.perspective.is_none(),
e.dimension.is_none(),
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -757,6 +761,7 @@ impl Serialize for Node {
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("live_paint_gaps", &e.live_paint_gaps);
st.end()
}
}
@@ -814,6 +819,8 @@ struct NodeWire {
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
}
impl From<NodeWire> for Node {
@@ -832,6 +839,7 @@ impl From<NodeWire> for Node {
perspective: w.perspective,
dimension: w.dimension,
intertwine: w.intertwine,
live_paint_gaps: w.live_paint_gaps,
};
Node {
id: w.id,
@@ -1217,6 +1225,13 @@ impl Node {
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
NodeKind::Text(t) => {
// The characters' strokes paint around the glyphs, scaled with the type.
let k = Scaling::factor(t.xf).unwrap_or(1.0);
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
let o = self.appearance.outset().max(chars);
self.geometric(symbols).map(|b| b.inflate(o, o))
}
_ => {
let o = self.appearance.outset();
self.geometric(symbols).map(|b| b.inflate(o, o))
@@ -1699,8 +1714,41 @@ pub struct ObjectAttributes {
/// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub data: Vec<(String, String)>,
/// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order
/// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as
/// the element's event attributes; nothing in VectorCraft runs them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<(String, String)>,
}
/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event
/// attributes that run it.
pub const SVG_EVENTS: [&str; 19] = [
"onfocusin",
"onfocusout",
"onactivate",
"onmousedown",
"onmouseup",
"onclick",
"onmouseover",
"onmousemove",
"onmouseout",
"onkeydown",
"onkeypress",
"onkeyup",
"onload",
"onerror",
"onabort",
"onunload",
"onzoom",
"onresize",
"onscroll",
];
/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the
/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another.
pub const MAX_SVG_SCRIPTS: usize = 64;
/// The Attributes panel's Image Map shapes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -2015,6 +2063,7 @@ mod tests {
e.attrs = Some(Box::default());
e.slice = Some(Box::default());
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
e.live_paint_gaps = Some(Box::default());
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -2056,6 +2105,7 @@ mod tests {
"slice",
"bbox_angle",
"perspective",
"live_paint_gaps",
];
assert_eq!(keys, order);
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
@@ -2336,6 +2386,25 @@ mod tests {
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
}
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
#[test]
fn type_visual_bounds_grow_with_the_character_stroke() {
use crate::appearance::StrokeAlign;
use crate::text::{CharStyle, TextObject};
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
t.xf = Affine::scale(2.0) * t.xf;
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
let g = node(&t).geometric_bounds().unwrap();
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
t.runs[0].style.stroke_align = align;
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
}
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
}
/// 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());
+9 -8
View File
@@ -94,26 +94,27 @@ impl Node {
}
}
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
/// printed plate this approximates their inks printing over the inks below. Darken keeps the
/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays
/// that colour, where Multiply would darken it. With `discard_white`
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
pub fn darken_overprints(a: &mut Arc<Node>, discard_white: bool) {
if !a.has_overprint() {
return;
}
let n = Arc::make_mut(a);
let multiplies =
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
for it in &mut n.appearance.items {
match it {
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
_ => {}
}
}
for c in n.children_mut().into_iter().flatten() {
multiply_overprints(c, discard_white);
darken_overprints(c, discard_white);
}
}
+87 -5
View File
@@ -248,9 +248,20 @@ impl PatternDef {
let ab = self.art_bounds().unwrap_or(self.tile).union(self.tile) - self.tile.origin().to_vec2();
let (sx, sy) = self.tile_type.steps(w, h);
// Shifts of brick/hex rows or columns are < one tile.
let (i0, i1) = (((region.x0 - ab.x1 - w) / sx).floor() as i64 - 1, ((region.x1 - ab.x0 + w) / sx).ceil() as i64 + 1);
let (j0, j1) = (((region.y0 - ab.y1 - h) / sy).floor() as i64 - 1, ((region.y1 - ab.y0 + h) / sy).ceil() as i64 + 1);
if (i1 - i0).saturating_mul(j1 - j0) > 250_000 {
// A region whose tile indices do not fit in integers (or are not finite) gives none, as does
// one with more than 250,000 positions or none.
let index = |v: f64| (v.abs() < 9.0e18).then_some(v as i64);
let (Some(i0), Some(i1), Some(j0), Some(j1)) = (
index(((region.x0 - ab.x1 - w) / sx).floor()),
index(((region.x1 - ab.x0 + w) / sx).ceil()),
index(((region.y0 - ab.y1 - h) / sy).floor()),
index(((region.y1 - ab.y0 + h) / sy).ceil()),
) else {
return vec![];
};
let (i0, i1, j0, j1) = (i0 - 1, i1 + 1, j0 - 1, j1 + 1);
let (ni, nj) = (i1.saturating_sub(i0), j1.saturating_sub(j0));
if ni < 1 || nj < 1 || ni.saturating_mul(nj) > 250_000 {
return vec![];
}
let mut out = vec![];
@@ -282,8 +293,15 @@ impl PatternDef {
out
}
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Expand.
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
pub fn instances_in(&self, paint_xf: Affine, region: Rect) -> Vec<Node> {
self.instances_in_with(paint_xf, region, false)
}
/// [`Self::instances_in`], each instance placed with `scaling` (what scales with `paint_xf`
/// besides geometry).
pub fn instances_in_with(&self, paint_xf: Affine, region: Rect, scaling: impl Into<crate::node::Scaling>) -> Vec<Node> {
let scaling = scaling.into();
let inv = paint_xf.inverse();
let pr = inv.transform_rect_bbox(region);
let mut out = vec![];
@@ -291,7 +309,7 @@ impl PatternDef {
let m = paint_xf * self.instance_xf(o);
for a in &self.art {
let mut n = (**a).clone();
n.transform(m, false);
place_art(&mut n, m, scaling);
zero_ids(&mut n);
out.push(n);
}
@@ -300,6 +318,31 @@ impl PatternDef {
}
}
/// Transform pattern art `n` by `m` with `scaling`, as a pattern places its art: the opacity masks
/// in it move with it, unlinked ones included (the canvas draws a tile's art and masks together).
pub fn place_art(n: &mut Node, m: Affine, scaling: impl Into<crate::node::Scaling>) {
let scaling = scaling.into();
n.transform(m, scaling);
place_unlinked_masks(n, m, scaling);
}
/// Transform the art of the unlinked opacity masks of `n` and its descendants by `m`, the masks
/// inside mask art included.
fn place_unlinked_masks(n: &mut Node, m: Affine, scaling: crate::node::Scaling) {
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
let linked = mask.linked;
let art = Arc::make_mut(&mut mask.art);
// A linked mask's art moved with `n`; an unlinked one's moves here.
if !linked {
art.transform(m, scaling);
}
place_unlinked_masks(art, m, scaling);
}
for c in n.children_mut().into_iter().flatten() {
place_unlinked_masks(Arc::make_mut(c), m, scaling);
}
}
fn zero_ids(n: &mut Node) {
n.id = NodeId(0);
if let Some(ch) = n.children_mut() {
@@ -670,6 +713,18 @@ mod tests {
assert!(close(*d2.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0)).last().unwrap(), Vec2::new(20.0, 10.0)));
}
#[test]
fn a_region_too_large_to_count_has_no_offsets() {
// A pattern scaled by 1e-20 covers a 100 pt square with a region of 1e22 pt.
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
assert!(def.offsets_covering(Rect::new(-1e22, -1e22, 1e22, 1e22)).is_empty());
assert!(def.offsets_covering(Rect::new(f64::NEG_INFINITY, 0.0, f64::INFINITY, 10.0)).is_empty());
// Far out, a region one tile wide and 1e12 pt tall still counts its rows.
assert!(def.offsets_covering(Rect::new(1e18, 0.0, 1e18, 1e12)).is_empty());
let tiny = Affine::scale(1e-20);
assert!(def.instances_in(tiny, Rect::new(0.0, 0.0, 100.0, 100.0)).is_empty());
}
#[test]
fn instances_follow_paint_xf() {
let def = PatternDef::new("p", vec![sq(100.0, 100.0, 10.0)]);
@@ -679,6 +734,33 @@ mod tests {
assert!(n.iter().all(|n| n.id == NodeId(0)));
}
#[test]
fn instances_carry_unlinked_masks_with_them() {
let mut art = (*sq(100.0, 100.0, 10.0)).clone();
let mut mask = crate::node::OpacityMask::new((*sq(100.0, 100.0, 5.0)).clone(), true);
mask.linked = false;
art.extras_mut().mask = Some(Box::new(mask));
let def = PatternDef::new("p", vec![Arc::new(art)]);
for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) {
let (b, m) = (n.geometric_bounds().unwrap(), n.extras().mask.as_ref().unwrap());
assert_eq!(m.art.geometric_bounds().unwrap().origin(), b.origin(), "the mask moves with its art");
assert!(!m.linked, "the mask stays unlinked");
}
// An unlinked mask inside a linked mask's art moves too.
let mut inner = (*sq(100.0, 100.0, 5.0)).clone();
let mut nested = crate::node::OpacityMask::new((*sq(100.0, 100.0, 2.0)).clone(), true);
nested.linked = false;
inner.extras_mut().mask = Some(Box::new(nested));
let mut art = (*sq(100.0, 100.0, 10.0)).clone();
art.extras_mut().mask = Some(Box::new(crate::node::OpacityMask::new(inner, true)));
let def = PatternDef::new("p", vec![Arc::new(art)]);
for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) {
let outer = n.extras().mask.as_ref().unwrap();
let nested = outer.art.extras().mask.as_ref().unwrap();
assert_eq!(nested.art.geometric_bounds().unwrap().origin(), n.geometric_bounds().unwrap().origin());
}
}
#[test]
fn preview_offsets_count() {
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
+124 -5
View File
@@ -11,15 +11,20 @@ use crate::ImageBlob;
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded, would take
/// more than `max` bytes as RGBA, or need more than `max` bytes to decode. The size is read from
/// the header first: the decoder's limit is on the file's samples, and a gray image is a quarter
/// of what it becomes here (#1717).
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) * 4 > max {
if u64::from(w) * u64::from(h) > max / 4 {
return None;
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max);
reader.limits(limits);
Some(reader.decode().ok()?.into_rgba8())
}
impl ImageBlob {
@@ -74,6 +79,59 @@ impl ImageBlob {
sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size)
}
/// The colours of a `rows` × `cols` mesh laid over the image: `(rows + 1) * (cols + 1)` straight
/// RGBA samples, row-major, one per grid line crossing (row 0 is the top). Each is the
/// alpha-weighted average of the area around the crossing, one grid cell wide and high (a
/// quarter of each of the neighbouring cells), not a single pixel. A crossing over transparent
/// pixels gets their average colour at alpha 0. `None` when `rows` or `cols` is 0 or the
/// image can't be decoded.
pub fn grid_samples(&self, rows: u32, cols: u32) -> Option<Vec<[u8; 4]>> {
if rows == 0 || cols == 0 {
return None;
}
let img = self.decoded()?;
let (w, h) = (img.width() as usize, img.height() as usize);
if w == 0 || h == 0 {
return None;
}
// The pixel columns [from, to) around grid line `i` of `n`, at least one pixel wide.
let span = |i: u32, n: u32, len: usize| {
let at = |k: f64| (k / n as f64 * len as f64).round().clamp(0.0, len as f64) as usize;
let (from, to) = (at(i as f64 - 0.5), at(i as f64 + 0.5));
let from = from.min(len - 1);
(from, to.max(from + 1))
};
let mut out = Vec::with_capacity((rows as usize + 1) * (cols as usize + 1));
for r in 0..=rows {
let (y0, y1) = span(r, rows, h);
for c in 0..=cols {
let (x0, x1) = span(c, cols, w);
let (mut sum, mut plain, mut alpha, mut n) = ([0u64; 3], [0u64; 3], 0u64, 0u64);
for y in y0..y1 {
for x in x0..x1 {
let [pr, pg, pb, pa] = img.get_pixel(x as u32, y as u32).0;
let a = pa as u64;
for (k, v) in [pr, pg, pb].into_iter().enumerate() {
sum[k] += v as u64 * a;
plain[k] += v as u64;
}
alpha += a;
n += 1;
}
}
let n = n.max(1);
out.push(if alpha == 0 {
let m = |k: usize| ((plain[k] + n / 2) / n) as u8;
[m(0), m(1), m(2), 0]
} else {
let m = |k: usize| ((sum[k] + alpha / 2) / alpha) as u8;
[m(0), m(1), m(2), ((alpha + n / 2) / n) as u8]
});
}
}
Some(out)
}
/// The decoded pixels (the last image decoded is kept for the next sample).
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
thread_local! {
@@ -131,6 +189,35 @@ mod tests {
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
/// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no
/// other pixels, and the decoder makes them transparent.
fn gif_with_one_red_pixel(w: u16, h: u16) -> Vec<u8> {
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&w.to_le_bytes());
gif.extend_from_slice(&h.to_le_bytes());
// Flags for a global color table of two colors, the background and aspect bytes, then the
// table: red and black.
gif.extend_from_slice(&[0x80, 0, 0, 255, 0, 0, 0, 0, 0]);
// A 1 × 1 image at (0, 0), its LZW data (clear, color 0, end), and the end of the file.
gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]);
gif
}
/// The pixels are decoded within the allowance, not within the decoder's own 512 MB: an RGBA
/// image of 11,586 × 11,586 pixels (536,941,584 bytes) is recolored and sampled.
#[test]
fn pixels_past_the_decoders_own_limit_are_decoded_within_the_allowance() {
let (w, h) = (11_586, 11_586);
let rgba = u64::from(w) * u64::from(h) * 4;
assert!(rgba > 512 << 20);
let gif = gif_with_one_red_pixel(w, h);
assert!(rgba_within(&gif, rgba - 1).is_none(), "past the allowance");
let b = ImageBlob::new("image/gif", gif);
assert!(b.map_rgb(|[r, g, bl]| [bl, g, r]).is_some(), "red becomes blue");
assert_eq!(b.sample(0.5, 0.5, 1), Some([255, 0, 0, 255]));
assert_eq!(b.sample(f64::from(w) - 0.5, f64::from(h) - 0.5, 1), None, "transparent");
}
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
/// the decoder's own limit lets through is four times that as RGBA.
#[test]
@@ -143,6 +230,38 @@ mod tests {
assert!(rgba_within(b"not an image", u64::MAX).is_none());
}
/// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds
/// no pixel data.
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"II*\0".to_vec();
out.extend(8u32.to_le_bytes());
// Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value.
let entries: [(u16, u16, u32); 9] =
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
out.extend((entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend(tag.to_le_bytes());
out.extend(kind.to_le_bytes());
out.extend(1u32.to_le_bytes());
out.extend(value.to_le_bytes());
}
out.extend(0u32.to_le_bytes());
out
}
/// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is
/// refused like any other past the allowance, and an image within the allowance still decodes.
#[test]
fn a_size_past_64_bits_as_rgba_is_not_decoded() {
let huge = tiff_header(1 << 31, 1 << 31);
assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none());
assert!(rgba_within(&huge, u64::MAX).is_none());
let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
let mut png = Vec::new();
gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35");
}
#[test]
fn samples_a_pixel_or_the_average_around_it() {
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
+22
View File
@@ -192,6 +192,16 @@ impl Background {
/// Largest bleed on any side, in points (1 inch).
pub const MAX_BLEED: f64 = 72.0;
/// Largest superscript or subscript size, and the farthest its baseline moves either way, in
/// percent of the font size.
pub const MAX_SCRIPT: f64 = 500.0;
/// Largest synthesized small capitals size, in percent of the font size.
pub const MAX_SMALL_CAPS: f64 = 200.0;
/// Returns `bleed` with each side kept to 0–[`MAX_BLEED`] points; a side that isn't finite becomes 0.
pub fn bleed_in_range(bleed: [f64; 4]) -> [f64; 4] {
bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 })
}
/// File → Document Setup.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -274,6 +284,18 @@ impl DocSetup {
pub fn flattener(&self) -> &str {
self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER)
}
/// Moves the bleed and the type proportions into the ranges Document Setup takes, each to the
/// nearer end: the bleed by [`bleed_in_range`], superscript and subscript sizes to
/// 1–[`MAX_SCRIPT`] and positions to ±[`MAX_SCRIPT`], the small caps size to
/// 1–[`MAX_SMALL_CAPS`].
pub fn keep_in_range(&mut self) {
self.bleed = bleed_in_range(self.bleed);
for m in [&mut self.superscript, &mut self.subscript] {
m.size = m.size.clamp(1.0, MAX_SCRIPT);
m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT);
}
self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS);
}
}
#[cfg(test)]
+49
View File
@@ -0,0 +1,49 @@
//! Symbol Options › Export Type: Graphic or Movie Clip
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
//!
//! For Illustrator-only work the choice has no canvas effect; it is kept for Animate / Flash /
//! HTML5 handoff. Illustrator's default is Movie Clip. Stored under [`SYMBOL_EXPORT_TYPE`] keyed
//! by symbol name; the default (Movie Clip) is omitted from the map.
use serde_json::Value;
/// Document unknown key: `{ symbolName: "graphic"|"movieClip" }`.
pub const SYMBOL_EXPORT_TYPE: &str = "symbolExportType";
/// Symbol Options › Export Type.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SymbolExportType {
/// Static / graphic symbol instance (Animate Graphic).
Graphic,
/// Default: Movie Clip for Animate / game handoff; instances still place the same on the canvas.
#[default]
MovieClip,
}
impl SymbolExportType {
pub const IDS: [&'static str; 2] = ["movieClip", "graphic"];
pub fn id(self) -> &'static str {
match self {
Self::Graphic => "graphic",
Self::MovieClip => "movieClip",
}
}
pub fn from_id(s: &str) -> Option<Self> {
match s {
"graphic" => Some(Self::Graphic),
"movieClip" => Some(Self::MovieClip),
_ => None,
}
}
/// True when this is the document default (nothing stored for the symbol).
pub fn is_default(self) -> bool {
matches!(self, Self::MovieClip)
}
pub fn from_value(v: &Value) -> Option<Self> {
v.as_str().and_then(Self::from_id)
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3
//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the
//! centre stretch
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
//!
//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left
//! and top edges and from the right and bottom edges, so they survive resizing the art. Like
//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas.
//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
use serde_json::{Value, json};
/// Document unknown key: `{ symbolName: [left, top, right, bottom] }`.
pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice";
/// The four 9-slice guides as fractions of the symbol's natural size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SymbolNineSlice {
/// Left guide, from the left edge.
pub left: f64,
/// Top guide, from the top edge.
pub top: f64,
/// Right guide, from the right edge.
pub right: f64,
/// Bottom guide, from the bottom edge.
pub bottom: f64,
}
impl Default for SymbolNineSlice {
/// The centre third of each axis is the stretchy cell.
fn default() -> Self {
let t = 1.0 / 3.0;
Self { left: t, top: t, right: t, bottom: t }
}
}
impl SymbolNineSlice {
/// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the
/// two on each axis leave a gap between them.
pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option<Self> {
let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
(ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom })
}
pub fn to_value(self) -> Value {
json!([self.left, self.top, self.right, self.bottom])
}
pub fn from_value(v: &Value) -> Option<Self> {
let a = v.as_array()?;
let n = |i: usize| a.get(i).and_then(Value::as_f64);
if a.len() != 4 {
return None;
}
Self::new(n(0)?, n(1)?, n(2)?, n(3)?)
}
}
+72 -5
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
@@ -84,6 +84,10 @@ pub struct CharStyle {
pub stroke_miter_limit: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_dash: Option<Dash>,
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub stroke_align: StrokeAlign,
/// Superscript or subscript (Character panel).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub position: CharPosition,
@@ -249,6 +253,7 @@ impl Default for CharStyle {
stroke_join: LineJoin::Miter,
stroke_miter_limit: 10.0,
stroke_dash: None,
stroke_align: StrokeAlign::Center,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
@@ -266,8 +271,9 @@ impl CharStyle {
pub fn has_stroke(&self) -> bool {
!self.stroke.is_none() && self.stroke_width > 0.0
}
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
/// and export.
pub fn stroke_layer(&self) -> StrokeLayer {
StrokeLayer {
overprint: self.overprint_stroke,
@@ -275,11 +281,13 @@ impl CharStyle {
join: self.stroke_join,
miter_limit: self.stroke_miter_limit,
dash: self.stroke_dash.clone(),
align: self.stroke_align,
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
}
}
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
/// character stroke (the options characters have no use for, such as arrowheads and width
/// profiles, are dropped).
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
self.stroke = st.paint.clone();
self.stroke_width = st.width;
@@ -287,6 +295,19 @@ impl CharStyle {
self.stroke_join = st.join;
self.stroke_miter_limit = st.miter_limit;
self.stroke_dash = st.dash.clone();
self.stroke_align = st.align;
}
/// How far the character stroke paints past the glyph outlines (text space): half the weight
/// centred, the whole weight outside, nothing inside (miter spikes aside).
pub fn stroke_outset(&self) -> f64 {
if !self.has_stroke() {
return 0.0;
}
match self.stroke_align {
StrokeAlign::Center => self.stroke_width / 2.0,
StrokeAlign::Outside => self.stroke_width,
StrokeAlign::Inside => 0.0,
}
}
/// The character fill as a fill layer, overprinting as the characters do.
fn fill_layer(&self) -> FillLayer {
@@ -360,6 +381,37 @@ impl Composer {
}
}
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
/// limit, no hyphenation zone, and capitalized words hyphenated.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HyphenationOptions {
/// Words Longer Than: only words with more letters than this are hyphenated.
pub words_longer_than: u32,
/// After First: the fewest letters a word keeps before the hyphen.
pub after_first: u32,
/// Before Last: the fewest letters a word keeps after the hyphen.
pub before_last: u32,
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
/// 0 sets no limit.
pub hyphen_limit: u32,
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
/// word already ends within this distance of its right edge breaks before that word instead
/// of hyphenating it. 0 allows every hyphenation.
pub zone: f64,
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
/// hyphenated. Words in capitals only are never hyphenated.
pub capitalized: bool,
}
impl Default for HyphenationOptions {
fn default() -> Self {
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
@@ -374,6 +426,8 @@ pub struct ParaStyle {
pub space_before: f64,
pub space_after: f64,
pub hyphenate: bool,
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
pub hyphenation: HyphenationOptions,
/// Line breaking: Single-line or Every-line Composer.
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
@@ -421,6 +475,8 @@ struct ParaStyleFile {
space_after: f64,
#[serde(default)]
hyphenate: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
hyphenation: HyphenationOptions,
#[serde(default, skip_serializing_if = "Composer::is_default")]
composer: Composer,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -454,6 +510,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -473,6 +530,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -495,6 +553,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -513,6 +572,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -1007,6 +1067,12 @@ pub struct TextObject {
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wrap: Vec<WrapShape>,
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
/// sides of letters such as A and W at a line's start or end hang outside the margin there
/// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off
/// for new type and for documents from before it, and saved only when on.
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
pub optical_margin: bool,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
@@ -1072,6 +1138,7 @@ impl TextObject {
path_align: PathAlign::default(),
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
cached_bounds: None,
cached_baselines: Vec::new(),
}
+16 -6
View File
@@ -116,6 +116,10 @@ pub struct Variables {
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_dataset: Option<String>,
/// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can
/// still be bound to them and unbound, and datasets still change and apply.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
}
impl Variables {
@@ -184,20 +188,26 @@ impl Document {
self.variables.bindings = bindings;
}
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
/// able to show it again, and a capture must record it hidden.
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
/// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't
/// count, as that is what a visibility variable drives.
pub fn variable_target(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
}
/// Whether a data set reads and rewrites the bound object `id`: it is in the document and
/// neither it nor anything above it is locked. Hidden objects count, and so do objects in a
/// hidden layer or group.
pub fn dataset_target(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked)))
}
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
/// link path yet (an embedded image), or is locked or in a locked layer or group (the
/// document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.variable_target(id) {
if !self.dataset_target(id) {
return None;
}
let node = self.node(id)?;
+17 -12
View File
@@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String {
out
}
/// The XML id for an object name (the inverse of [`to_object_name`]).
/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty,
/// since a bare `_` would read back as a space.
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
for (i, c) in name.chars().enumerate() {
@@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
} else if i == 0 && c.is_ascii_digit() {
} else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') {
// A first character that cannot start an XML name is escaped, never given a bare `_`
// prefix (that would read back as a space).
out.push_str(&format!("_x{:X}_", u32::from(c)));
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
out.push(c);
} else {
out.push_str(&format!("_x{:X}_", u32::from(c)));
out.push(c);
}
}
if out.is_empty() || !starts_name(&out) {
out.insert(0, '_');
}
out
}
@@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool {
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
fn starts_name(id: &str) -> bool {
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
@@ -114,4 +108,15 @@ mod tests {
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
}
#[test]
fn round_trip_names_that_cannot_start_an_xml_name() {
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
let id = from_object_name(name);
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
}
assert_eq!(from_object_name("-abc"), "_x2D_abc");
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
assert_eq!(from_object_name("²x"), "_xB2_x");
}
}
+16
View File
@@ -490,4 +490,20 @@ mod tests {
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
assert!(out.appearance.items[1].effects().is_empty());
}
#[test]
fn adjust_recolours_the_points_of_a_freeform_gradient() {
use vectorcraft_doc::color::{Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint};
let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() });
let pt = |x, c| FreeformPoint::new(kurbo::Point::new(x, 5.0), c);
gp.set_freeform(Freeform { points: vec![pt(2.0, Color::rgb(1.0, 0.0, 0.0)), pt(8.0, Color::rgb(0.0, 0.0, 1.0))], ..Default::default() });
let fill = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0);
let mut p = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill);
p.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
let out = adjust(&p, None, &mut |_, _| None).unwrap();
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient fill") };
// The points are what a freeform gradient draws from, not its stops.
let hexes: Vec<String> = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect();
assert_eq!(hexes, ["#00ff00", "#ff0000"]);
}
}
+135 -7
View File
@@ -28,7 +28,7 @@ use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -89,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
/// `n` with its geometry effects evaluated (`None` = unchanged).
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) {
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() {
return None;
}
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
@@ -106,9 +107,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = evaluate_container(&m).unwrap_or(m);
return Some(bake_pieces(d, m));
}
// Type, images, symbol instances and live objects: reshaped through their outlines. Type that
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
// Transform effects only move and copy stays type, moved to each place.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
@@ -119,6 +121,13 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
// A path whose Transform effect copies it with a gradient: each copy an object of its own, so
// its gradient fits it rather than spanning every copy.
if crate::has_gradient_copies(n)
&& let Some(m) = reshape(n, None)
{
return Some(bake_pieces(d, m));
}
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
// fills' and strokes' art) get ids of their own.
@@ -504,7 +513,8 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an
/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path
/// painted by that fill and every visible stroke without a brush its filled outline in the
/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as
/// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke:
/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
@@ -534,8 +544,28 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
clear_item_effects(&mut it);
pieces.push(match &it {
AppearanceItem::Stroke(st) if st.brush.is_none() => {
let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) };
piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill))
let outline = crate::stroke::outline_region(&ig, rule, st);
match st.path_gradient() {
// A gradient along or across the stroke: its gradient meshes clipped to the outline.
Some(g) => {
let mut clip = Node::path(n.id, outline, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let meshes = crate::stroke::gradient_meshes(&ig.to_bezpath(), rule, st, &g.gradient);
let meshes = meshes.into_iter().map(|m| Node::new(n.id, NodeKind::Mesh(m)));
let children = std::iter::once(clip).chain(meshes).map(Arc::new).collect();
let mut group = Node::new(n.id, NodeKind::Group { children, clip: true });
(group.opacity, group.blend) = (st.opacity, st.blend);
group.appearance.effects = st.effects.clone();
Arc::new(group)
}
None => {
let effects = st.effects.clone();
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) };
piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill))
}
}
}
_ => piece(ig, rule, it),
});
@@ -571,6 +601,15 @@ mod tests {
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
fn skip_without_craft_fonts() -> bool {
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
if skip {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
skip
}
fn fx(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
@@ -618,6 +657,36 @@ mod tests {
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
}
#[test]
fn a_baked_stroke_keeps_its_raster_effects() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
s.effects.push(fx("stylize.dropShadow", json!({})));
}
let m = bake_appearance(&n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
}
#[test]
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
s.width = 10.0;
s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along;
s.opacity = 0.5;
}
let m = bake_appearance(&n).unwrap();
let ch = m.children().unwrap();
let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes");
assert_eq!(ch[1].opacity, 0.5);
}
/// Black 24 pt "Type" with its baseline at (20, 60).
fn type_node(id: u64) -> Node {
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
@@ -646,6 +715,9 @@ mod tests {
#[test]
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
if skip_without_craft_fonts() {
return;
}
for (params, origins, square) in [
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
@@ -677,6 +749,9 @@ mod tests {
#[test]
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut hidden = type_node(55);
hidden.visible = false;
@@ -688,6 +763,9 @@ mod tests {
#[test]
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut shadowed = type_node(55);
shadowed.opacity = 0.5;
@@ -710,6 +788,9 @@ mod tests {
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
let mut moved = type_node(60);
@@ -728,6 +809,9 @@ mod tests {
#[test]
fn type_with_its_own_transform_effect_copies_stays_type() {
if skip_without_craft_fonts() {
return;
}
let mut n = type_node(50);
n.opacity = 0.5;
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
@@ -774,6 +858,9 @@ mod tests {
#[test]
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
// A 20 pt stroke on the characters reaches the copy below.
@@ -789,6 +876,9 @@ mod tests {
#[test]
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
let mut translucent = type_node(50);
translucent.opacity = 0.5;
@@ -861,6 +951,9 @@ mod tests {
#[test]
fn type_whose_copies_hold_too_much_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let spaced = |spaces: usize| {
let mut n = type_node(50);
if let NodeKind::Text(t) = &mut n.kind {
@@ -898,6 +991,9 @@ mod tests {
#[test]
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
inner.id = NodeId(53);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
@@ -931,4 +1027,36 @@ mod tests {
// An effect that also scales.
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
}
/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies.
fn gradient_copies(id: u64) -> Node {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
n
}
/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right.
fn gradient_pieces(n: &Node) -> Vec<f64> {
let mut out = vec![];
let mut walk = vec![n];
while let Some(n) = walk.pop() {
match n.children() {
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)),
_ => {
assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}");
out.extend(n.geometric_bounds().map(|b| b.width()));
}
}
}
out
}
#[test]
fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
let d = bake_document(&doc_with(gradient_copies(50))).unwrap();
// Three squares of their own, not one path spanning all three.
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
}
}
+32 -10
View File
@@ -267,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the
/// path to the ridges before and after it (zero at an open path's ends).
struct Ridge {
p: Point,
dir: Vec2,
gap_in: f64,
gap_out: f64,
}
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
/// displaced along the normal.
/// displaced along the normal. Corner points join them with straight lines. Smooth points make a
/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the
/// way to the next ridge, so each span between them follows a half cosine (an S through the path)
/// whatever the size of the ridges (#2343).
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
@@ -277,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let mut subs = vec![];
for sp in &path.subpaths {
let segs = sp.segment_count();
let mut pts = vec![];
let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64;
let mut pts: Vec<Ridge> = vec![];
let mut k = 0usize;
for i in 0..segs {
let c = sp.segment(i);
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
// near the anchors, and so does any uneven curve.
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
let step = len / (ridges + 1) as f64;
for j in 0..=ridges {
let s = j as f64 / (ridges + 1) as f64;
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
let mut gap_in = step;
let nrm = if j == 0 {
// Average the normals of the segments meeting at the anchor.
let prev = if i > 0 {
Some(sp.segment(i - 1))
Some(i - 1)
} else if sp.closed {
Some(sp.segment(segs - 1))
Some(segs - 1)
} else {
None
};
gap_in = prev.map_or(0.0, gap);
let n1 = normal_at(&c, 0.0);
match prev {
Some(pc) => {
let s = n1 + normal_at(&pc, 1.0);
Some(pi) => {
let s = n1 + normal_at(&sp.segment(pi), 1.0);
if s.hypot() > 1e-9 { s / s.hypot() } else { n1 }
}
None => n1,
@@ -308,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
normal_at(&c, t)
};
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
pts.push(c.eval(t) + nrm * (amp * sign));
pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step });
k += 1;
}
}
@@ -316,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
if segs > 0 {
let c = sp.segment(segs - 1);
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign));
let nrm = normal_at(&c, 1.0);
pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 });
} else {
pts.extend(sp.anchors.iter().map(|a| a.p));
pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 }));
}
}
subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) });
subs.push(if smooth {
let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect();
SubPath::new(anchors, sp.closed)
} else {
SubPath::polyline(&pts.iter().map(|r| r.p).collect::<Vec<_>>(), sp.closed)
});
}
PathData::new(subs)
}
+49
View File
@@ -293,6 +293,15 @@ mod tests {
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
fn skip_without_craft_fonts() -> bool {
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
if skip {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
skip
}
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
Arc::new(Node::path(
NodeId(id),
@@ -350,6 +359,9 @@ mod tests {
#[test]
fn type_changes_the_result_of_every_pathfinder_effect() {
if skip_without_craft_fonts() {
return;
}
let glyphs = crate::outline_text(&type_h()).unwrap();
let b = glyphs.geometric_bounds().unwrap();
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
@@ -377,6 +389,9 @@ mod tests {
#[test]
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
if skip_without_craft_fonts() {
return;
}
let mut t = type_h();
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
@@ -418,6 +433,9 @@ mod tests {
#[test]
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
if skip_without_craft_fonts() {
return;
}
use vectorcraft_color::BlendMode;
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
@@ -447,6 +465,9 @@ mod tests {
#[test]
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
if skip_without_craft_fonts() {
return;
}
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
@@ -470,4 +491,32 @@ mod tests {
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
}
#[test]
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
for (k, paint) in paints.into_iter().enumerate() {
let square =
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)));
let mut g = Node::group(NodeId(10), vec![square]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(&g).unwrap();
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
assert_eq!(copies.len(), 3);
for (i, c) in copies.iter().enumerate() {
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
match c.appearance.fill_paint() {
Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"),
Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"),
p => panic!("a gradient: {p:?}"),
}
}
}
}
}
+43 -3
View File
@@ -6,7 +6,7 @@
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
//! Unsharp Mask and Glowing Edges) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
//! the geometry.
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
@@ -149,6 +149,7 @@ fn lengths_of(id: &str) -> Lengths {
"texture.mosaicTiles" => always(&["tileSize"]),
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
"sketch.halftonePattern" => always(&["size"]),
_ => Lengths::default(),
}
}
@@ -165,6 +166,7 @@ const ARTISTIC: &[&str] = &["Effect", "Artistic"];
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
const DISTORT: &[&str] = &["Effect", "Distort"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
const SKETCH: &[&str] = &["Effect", "Sketch"];
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
const TEXTURE: &[&str] = &["Effect", "Texture"];
const VIDEO: &[&str] = &["Effect", "Video"];
@@ -289,6 +291,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
),
g(
"path.outlineObject",
"Outline Object",
PATH,
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change",
json!({}),
),
g(
"path.outlineStroke",
"Outline Stroke",
@@ -327,6 +336,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.cutout",
"Cutout…",
ARTISTIC,
"{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}),
),
r(
"artistic.filmGrain",
"Film Grain…",
@@ -334,6 +350,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{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(
"artistic.neonGlow",
"Neon Glow…",
ARTISTIC,
"{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}),
),
r(
"artistic.posterEdges",
"Poster Edges…",
ARTISTIC,
"{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}),
),
r(
"blur.radial",
"Radial Blur…",
@@ -345,7 +375,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"blur.smart",
"Smart Blur…",
BLUR,
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's; the white and the black keep each pixel's coverage, and the radius changes only the blur",
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of the pixels the search reads within the radius (all of them for a small radius, a grid of samples for a wide one) or one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's, so a step gives a line about as wide as the radius, and at least one pixel wide, on the side with the larger sum; the white and the black keep each pixel's coverage",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}),
),
r(
@@ -432,6 +462,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
),
r(
"sketch.halftonePattern",
"Halftone Pattern…",
SKETCH,
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
),
r(
"stylize.glowingEdges",
"Glowing Edges…",
@@ -865,6 +902,9 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
}
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
}
// Outline Object returns the geometry unchanged. Type reaches every geometry effect as its
// glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines.
"path.outlineObject" => path.clone(),
"path.outlineStroke" => {
let fallback;
let base = match ctx.stroke {
+136 -2
View File
@@ -1,11 +1,13 @@
//! Effect › Artistic. Original filters built from public behaviour documentation:
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). 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::edges::sobel;
use super::pixelate::{Place, cell, rand};
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, blur_plane};
use vectorcraft_geom::Point;
const SALT_FILM: u64 = 256;
@@ -134,3 +136,135 @@ pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSp
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// The coverage-weighted Gaussian softening of the planes `planes` (premultiplied by `alpha`) by
/// `sigma` pixels: each divided by the softened coverage, so transparency doesn't darken edges.
fn soften(planes: &mut [Vec<f32>], alpha: &[f32], w: usize, h: usize, sigma: f64) {
let mut a = alpha.to_vec();
blur_plane(&mut a, w, h, sigma);
for plane in planes {
for (v, c) in plane.iter_mut().zip(alpha) {
*v *= c;
}
blur_plane(plane, w, h, sigma);
for (v, c) in plane.iter_mut().zip(&a) {
*v = if *c > 1e-4 { (*v / c).clamp(0.0, 1.0) } else { 0.0 };
}
}
}
/// The pixels' straight colour channels and their coverage (0..1), as planes.
fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
let colours = px.iter().map(|p| straight(*p)).collect::<Vec<_>>();
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
}
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
/// colours keep their hue. Slivers (the blends along softened edges) take the nearest large
/// piece's colour, so the pieces meet with crisp edges. Coverage is kept.
pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, levels: f64, simplicity: f64, fidelity: f64) {
if ![levels, simplicity, fidelity].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
return;
}
let levels = levels.round().clamp(2.0, 8.0) as usize;
// Simpler edges come from softer colours; more fidelity softens them less.
let sigma = 0.6 * simplicity.clamp(0.0, 10.0) * (4.0 - fidelity.clamp(1.0, 3.0)) / 3.0 / space.px;
let (mut colour, alpha) = planes(px);
soften(&mut colour, &alpha, w, h, sigma);
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
let piece = |c: [f32; 3]| c.iter().fold(0, |n, v| n * levels + ((v * levels as f32) as usize).min(levels - 1));
// Each piece's colour: the coverage-weighted mean of the softened colours in it.
let mut sums = vec![[0.0f64; 4]; levels * levels * levels];
for (k, a) in alpha.iter().enumerate() {
let c = at(k);
let Some(sum) = sums.get_mut(piece(c)) else { continue };
for (s, v) in sum.iter_mut().zip(c) {
*s += f64::from(v * a);
}
sum[3] += f64::from(*a);
}
let mean = |sum: &[f64; 4]| [0, 1, 2].map(|i| (sum[i] / sum[3]) as f32);
let total: f64 = sums.iter().map(|s| s[3]).sum();
let heaviest = sums.iter().map(|s| s[3]).fold(0.0, f64::max);
// Pieces of at least 1% of the object (and always the largest one) are kept.
let large: Vec<[f32; 3]> = sums.iter().filter(|s| s[3] > 0.0 && (s[3] >= 0.01 * total || s[3] >= heaviest)).map(mean).collect();
let paint: Vec<Option<[f32; 3]>> = sums
.iter()
.map(|s| {
let c = (s[3] > 0.0).then(|| mean(s))?;
let far = |o: &[f32; 3]| o.iter().zip(c).map(|(a, b)| (a - b) * (a - b)).sum::<f32>();
large.iter().min_by(|a, b| far(a).total_cmp(&far(b))).copied()
})
.collect();
for (k, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
if let Some(Some(c)) = paint.get(piece(at(k))) {
*p = premultiply(*c, f32::from(p[3]) / 255.0);
}
}
}
/// Poster Edges: the object's colours posterized to `posterization` + 2 (2..8) levels per
/// channel, with black lines drawn on the edges of any of its colour channels and of its outline. `thickness` (0..10) widens the lines and
/// `intensity` (0..10) darkens them and lets fainter edges take a line. Coverage is kept.
pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, intensity: f64, posterization: f64) {
if ![thickness, intensity, posterization].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
return;
}
let steps = (posterization.round().clamp(0.0, 6.0) + 1.0) as f32;
// The edges of each premultiplied channel (so the object's outline too), a step of 1 reading 1.
let mut edges = vec![0.0f32; px.len()];
for k in 0..4 {
let plane: Vec<f32> = px.iter().map(|p| f32::from(p[k]) / 255.0).collect();
for (e, s) in edges.iter_mut().zip(sobel(&plane, w, h)) {
*e = e.max(s / 4.0);
}
}
// Wider lines spread the edges; scale them back up so a spread line stays as dark.
let sigma = (0.3 + 0.25 * thickness.clamp(0.0, 10.0)) / space.px;
blur_plane(&mut edges, w, h, sigma);
let gain = ((0.3 + 0.25 * intensity.clamp(0.0, 10.0)) * (1.0 + 1.5 * sigma.max(0.0))) as f32;
for (p, e) in px.iter_mut().zip(edges) {
if p[3] == 0 {
continue;
}
let t = ((e * gain - 0.25) / 0.5).clamp(0.0, 1.0);
let ink = t * t * (3.0 - 2.0 * t);
let c = straight(*p).map(|v| (v * steps).round() / steps * (1.0 - ink));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
}
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
pub(super) fn glow_spread(size: f64) -> f64 {
0.25 * size.abs().min(24.0)
}
/// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size`
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
/// strong as `brightness` (0..50). Coverage is kept.
pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, brightness: f64, colour: [f32; 3]) {
if ![size, brightness].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
return;
}
let strength = (brightness / 50.0).clamp(0.0, 1.0) as f32;
let tones: Vec<f32> = px.iter().map(|p| luma(straight(*p))).collect();
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let mut glow = [tones.iter().map(|l| if size < 0.0 { 1.0 - l } else { *l }).collect::<Vec<_>>()];
soften(&mut glow, &alpha, w, h, glow_spread(size) / space.px);
let [glow] = glow;
for ((p, tone), g) in px.iter_mut().zip(tones).zip(glow) {
if p[3] == 0 {
continue;
}
// The greys dim as the glow brightens, and the glow is screened over them.
let base = tone * (1.0 - 0.6 * strength);
let lit = (3.0 * g * strength).clamp(0.0, 1.0);
let c = colour.map(|v| 1.0 - (1.0 - base) * (1.0 - v.clamp(0.0, 1.0) * lit));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
}
+67 -41
View File
@@ -102,6 +102,40 @@ fn close(a: &Px16, b: &Px16, t: i32) -> bool {
a.iter().zip(b).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t)
}
/// The offsets Smart Blur looks at around a pixel out to about `r` pixels (up to round(`r`)),
/// with the radius of the box blur of the copy they are read from (0: the pixels themselves):
/// every pixel of the square of side 2 · round(`r`) + 1, or `samples` × `samples` points spread
/// over the square once it is wider than `samples` pixels, read from a copy box-blurred over the
/// gaps between them. The callers keep the offsets inside their disc.
fn smart_grid(r: f64, samples: u32) -> (Vec<Vec2>, usize) {
let n = samples.clamp(3, 15) as f64;
let k = r.round();
if 2.0 * k < n {
let k = k as i32;
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
} else {
let gap = 2.0 * r / (n - 1.0);
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
}
}
/// How far from a pixel Smart Blur's edge modes ([`smart_edges`]) read, in the units of `radius`:
/// `radius`, and the box blur of the copy that a wide radius's offsets are read from.
pub(super) fn edge_reach(radius: f64, samples: u32) -> f64 {
radius + radius / (samples.clamp(3, 15) as f64 - 1.0)
}
/// `src` (rows of `w` pixels) box-blurred with radius `prefilter`, the copy that the offsets of
/// [`smart_grid`] are read from; `None` when they are read from `src` itself.
fn smart_near(src: &[Px16], w: usize, prefilter: usize) -> Option<Vec<Px16>> {
(prefilter > 0).then(|| {
let mut b = src.to_vec();
box_blur(&mut b, w, prefilter);
b
})
}
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
@@ -113,28 +147,12 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
return;
}
let r = radius.min(MAX_SMART);
let n = samples.clamp(3, 15) as f64;
let k = r.round();
let (offsets, prefilter): (Vec<Vec2>, usize) = if 2.0 * k < n {
let k = k as i32;
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
} else {
let gap = 2.0 * r / (n - 1.0);
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
};
let (offsets, prefilter) = smart_grid(r, samples);
let offsets: Vec<(isize, isize)> =
offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect();
let src = to16(px);
let blurred;
let near: &[Px16] = if prefilter > 0 {
let mut b = src.clone();
box_blur(&mut b, w, prefilter);
blurred = b;
&blurred
} else {
&src
};
let blurred = smart_near(&src, w, prefilter);
let near = blurred.as_deref().unwrap_or(&src);
let t = levels(threshold);
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
@@ -162,33 +180,41 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
/// Smart Blur's Edge Only (`overlay` false) and Overlay Edge (`overlay` true) modes: the pixels on
/// an edge drawn white, over black or over the Normal mode's result ([`smart`]). A pixel is on an
/// edge when one of its four nearest neighbors (transparent beyond the raster) has a premultiplied
/// channel more than `threshold` levels from its own and its channels add up to at least the
/// neighbor's, so a step between two colors gives a line one pixel wide on the side whose channels
/// add up to more, and two pixels wide when the sums are equal. The white and the black keep the
/// pixel's coverage.
/// edge when one of the pixels the search reads within `radius` pixels of it (transparent beyond
/// the raster) has a premultiplied channel more than `threshold` levels from its own and its
/// channels add up to at least that pixel's. The search reads the Normal mode's offsets ([`smart_grid`]) for the whole
/// pixels of `radius`, kept within `radius`, from the pixels and, for a wide radius, from the
/// box-blurred copy, and always the four nearest pixels. A straight step between two colors gives
/// a line on the side whose channels add up to more, about `radius` wide (the whole pixels of
/// `radius` while every pixel within it is read, and at least one pixel), and lines on both sides
/// when the sums are equal. The white and the black keep the pixel's coverage.
pub(super) fn smart_edges(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32, overlay: bool) {
let src = to16(px);
let t = levels(threshold);
let sum = |p: &Px16| p.iter().map(|v| u32::from(*v)).sum::<u32>();
let at = |x: usize, y: usize, dx: isize, dy: isize| -> Px16 {
let (Some(x), Some(y)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { return [0; 4] };
if x >= w || y >= h {
return [0; 4];
let r = if radius.is_nan() { 0.0 } else { radius.min(MAX_SMART) };
// Spread over whole pixels, so that the outermost offsets lie within `r` and are kept.
let (offsets, prefilter) = smart_grid(r.floor(), samples);
let wide: Vec<(isize, isize)> = offsets.into_iter().filter(|o| o.hypot() > 1.0 && o.hypot() <= r).map(|o| (o.x as isize, o.y as isize)).collect();
let nearest = [(1, 0), (-1, 0), (0, 1), (0, -1)];
let blurred = smart_near(&src, w, prefilter);
let mut edge = vec![false; src.len()];
for (y, (row, out)) in src.chunks_exact(w).zip(edge.chunks_exact_mut(w)).enumerate().take(h) {
for (x, (c, e)) in row.iter().zip(out.iter_mut()).enumerate() {
let own = sum(c);
let differs = |n: &Px16| !close(n, c, t) && own >= sum(n);
// The pixel at the offset and, with `copy`, its blurred copy; transparent beyond the raster.
let found = |(dx, dy): &(isize, isize), copy: bool| {
let (sx, sy) = (x as isize + dx, y as isize + dy);
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
return differs(&[0; 4]);
}
let i = sy as usize * w + sx as usize;
src.get(i).is_some_and(differs) || (copy && blurred.as_deref().and_then(|b| b.get(i)).is_some_and(differs))
};
*e = nearest.iter().any(|o| found(o, false)) || wide.iter().any(|o| found(o, true));
}
src.get(y * w + x).copied().unwrap_or([0; 4])
};
let edge: Vec<bool> = src
.iter()
.enumerate()
.map(|(i, c)| {
let (x, y) = (i % w.max(1), i / w.max(1));
[(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
let n = at(x, y, *dx, *dy);
!close(&n, c, t) && sum(c) >= sum(&n)
})
})
.collect();
}
if overlay {
smart(px, w, h, radius, threshold, samples);
}
+64 -9
View File
@@ -1,7 +1,8 @@
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, 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
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
//! Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//!
@@ -20,6 +21,7 @@ mod edges;
mod loaded;
mod pixelate;
mod sharpen;
mod sketch;
mod texture;
mod video;
@@ -33,11 +35,15 @@ pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use sketch::{HALFTONE_TYPES, HalftonePattern};
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; 28] = [
pub const PIXEL_EFFECTS: [&str; 32] = [
"artistic.cutout",
"artistic.filmGrain",
"artistic.neonGlow",
"artistic.posterEdges",
"blur.radial",
"blur.smart",
"brushStrokes.accentedEdges",
@@ -56,6 +62,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
"pixelate.mezzotint",
"pixelate.pointillize",
"sharpen.unsharpMask",
"sketch.halftonePattern",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
@@ -71,18 +78,27 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
/// are in document units.
#[derive(Clone, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their
/// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say.
Cutout { levels: f64, simplicity: f64, fidelity: f64 },
/// 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 },
/// Artistic › Neon Glow: dimmed greys lit by `colour` (straight RGB, 0..1) glowing from the
/// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as
/// strong as `brightness` (0..50).
NeonGlow { size: f64, brightness: f64, colour: [f32; 3] },
/// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black
/// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10).
PosterEdges { thickness: f64, intensity: f64, posterization: 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 },
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
/// `samples` × `samples` neighbours at most.
/// The Edge Only `mode` draws the edges where neighboring pixels differ by more than
/// `threshold` levels in white on black, and Overlay Edge draws them in white over the Normal
/// result.
/// The Edge Only `mode` draws the edges that `threshold` and `radius` find (`blur::smart_edges`)
/// in white on black, and Overlay Edge draws them in white over the Normal result.
SmartBlur { radius: f64, threshold: f64, samples: u32, mode: SmartBlurMode },
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
@@ -123,6 +139,9 @@ pub enum PixelFx {
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
@@ -203,6 +222,21 @@ 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.cutout" => PixelFx::Cutout {
levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0),
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0),
},
"artistic.neonGlow" => PixelFx::NeonGlow {
size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0),
brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0),
colour: crate::raster::color_param(p, vectorcraft_doc::color::Color::rgb(0.3, 0.65, 1.0)).to_rgb(),
},
"artistic.posterEdges" => PixelFx::PosterEdges {
thickness: num(p, "edgeThickness", 2.0).clamp(0.0, 10.0),
intensity: num(p, "edgeIntensity", 1.0).clamp(0.0, 10.0),
posterization: num(p, "posterization", 2.0).round().clamp(0.0, 6.0),
},
"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),
@@ -277,6 +311,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
},
"sketch.halftonePattern" => PixelFx::HalftonePattern {
size: num(p, "size", 1.0).clamp(1.0, 12.0),
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
},
"stylize.glowingEdges" => PixelFx::GlowingEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
@@ -353,9 +392,12 @@ impl PixelFx {
// Content shifted outwards reaches past the bounds by as much as the shift.
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::FilmGrain { .. }
| PixelFx::Cutout { .. }
| PixelFx::NeonGlow { .. }
| PixelFx::PosterEdges { .. }
| PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
@@ -376,7 +418,8 @@ impl PixelFx {
pub fn reach(&self) -> Option<f64> {
match *self {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, .. } => Some(radius),
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)),
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
// The strokes, and the softened tones that choose them.
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
@@ -396,7 +439,13 @@ 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::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
// Cutout's palette comes from the whole object.
PixelFx::Cutout { .. } => None,
// The softened tones around the pixel.
PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)),
// The edges' lines, spread round them.
PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
@@ -417,7 +466,12 @@ 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::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour),
PixelFx::PosterEdges { thickness, intensity, posterization } => {
artistic::poster_edges(px, w, h, space, thickness, intensity, posterization)
}
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
blur::smart(px, w, h, to_px(radius), threshold, samples)
@@ -439,6 +493,7 @@ impl PixelFx {
}
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
+71
View File
@@ -0,0 +1,71 @@
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
use super::pixelate::Place;
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, pick};
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
/// A Halftone Pattern screen ([`HALFTONE_TYPES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HalftonePattern {
/// Rings round the object's centre.
Circle,
/// Dots on a square grid.
Dot,
/// Horizontal lines.
Line,
}
impl HalftonePattern {
const ALL: [HalftonePattern; 3] = [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line];
/// The screen of Pattern Type `value` (a [`HALFTONE_TYPES`] value, any case); Dot otherwise.
pub fn parse(value: &str) -> Self {
pick(&HALFTONE_TYPES, &Self::ALL, value, HalftonePattern::Dot)
}
}
/// Halftone Pattern: the object redrawn in black ink on white paper through a `kind` screen whose
/// cells are `size` points apart, anchored at the object's centre. The ink covers as much of a
/// cell as the colour is dark; `contrast` (0..50) hardens the screen's soft edges, to pure black
/// and white at 50. Coverage is kept.
pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, contrast: f64, kind: HalftonePattern) {
if !(size.is_finite() && size > 0.0 && contrast.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
// The width of the screen's soft edge, in tones: 0 is a hard threshold.
let soft = (0.5 * (1.0 - contrast / 50.0)).clamp(0.0, 0.5);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let band = |v: f64| (2.0 * ((v / size).rem_euclid(1.0) - 0.5)).abs();
// Where in its cell the pixel lies, 0 where the ink starts to 1 where it ends.
let screen = match kind {
HalftonePattern::Line => band(q.y),
HalftonePattern::Circle => band(q.x.hypot(q.y)),
HalftonePattern::Dot => 0.5 * (band(q.x).powi(2) + band(q.y).powi(2)),
};
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
let ink = if dark >= 1.0 {
1.0
} else if soft > 0.0 {
// The threshold kept inside [soft / 2, 1 - soft / 2], so white stays white and black black.
let threshold = 0.5 * soft + screen * (1.0 - soft);
((dark - threshold) / soft + 0.5).clamp(0.0, 1.0)
} else if dark > screen {
1.0
} else {
0.0
};
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
});
}
+364 -11
View File
@@ -500,6 +500,9 @@ fn outsets_follow_the_object() {
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(), Some(4.0));
// The edge modes also read the copy blurred over a wide radius's samples.
assert_eq!(fx("blur.smart", json!({"radius": 4, "quality": "low", "blurMode": "edgeOnly"})).reach(), Some(5.0));
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
assert_eq!(spin.reach(), None);
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
@@ -656,8 +659,8 @@ fn smart_blur_normal_mode_keeps_its_pixels() {
/// Smart Blur's test image: grays 56 and 64 in a checkerboard for x < 3 and 66 and 74 for x in 3..6
/// (steps of 2 or 18 levels at x = 3, under the threshold), gray 200 from x = 6 on (steps of 126 or
/// 134 levels). Its edges at threshold 25 are the pixels on the raster's border (transparency is
/// beyond it) and column 6, the step's lighter side.
/// 134 levels). Its edges at threshold 25 and radius 1 are the pixels on the raster's border
/// (transparency is beyond it) and column 6, the step's lighter side.
fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool) {
let (w, h) = (12, 8);
let src = image(w, h, |x, y| match x {
@@ -672,16 +675,16 @@ fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool)
fn smart_blur_edge_only_draws_the_edges_white_on_black() {
let (w, h, src, edge) = smart_blur_steps();
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut d, w, h, &space(w, h));
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1})).apply(&mut d, w, h, &space(w, h));
let want = image(w, h, |x, y| if edge(x, y) { [255; 4] } else { [0, 0, 0, 255] });
assert_eq!(d, want);
// The radius changes only the blur.
let mut wide = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut wide, w, h, &space(w, h));
assert_eq!(wide, want);
// Under one pixel the search still takes the four nearest pixels.
let mut narrow = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 0.1})).apply(&mut narrow, w, h, &space(w, h));
assert_eq!(narrow, want);
// A threshold under the checkerboard's 8 levels makes its lighter squares edges too.
let mut low = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "threshold": 5})).apply(&mut low, w, h, &space(w, h));
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1, "threshold": 5})).apply(&mut low, w, h, &space(w, h));
for y in 1..h - 1 {
for x in 1..6 {
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
@@ -692,7 +695,7 @@ fn smart_blur_edge_only_draws_the_edges_white_on_black() {
let (w, h) = (8, 8);
let shape = image(w, h, |x, y| if (2..6).contains(&x) && (2..6).contains(&y) { [64, 0, 0, 128] } else { [0; 4] });
let mut s = shape.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut s, w, h, &space(w, h));
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1})).apply(&mut s, w, h, &space(w, h));
let ring = |x: usize, y: usize| (2..6).contains(&x) && (2..6).contains(&y) && !((3..5).contains(&x) && (3..5).contains(&y));
let inside = |x: usize, y: usize| (3..5).contains(&x) && (3..5).contains(&y);
let want = image(w, h, |x, y| match (ring(x, y), inside(x, y)) {
@@ -707,10 +710,10 @@ fn smart_blur_edge_only_draws_the_edges_white_on_black() {
fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
let (w, h, src, edge) = smart_blur_steps();
let mut normal = src.clone();
fx("blur.smart", json!({})).apply(&mut normal, w, h, &space(w, h));
fx("blur.smart", json!({"radius": 1})).apply(&mut normal, w, h, &space(w, h));
assert_ne!(normal, src, "the checkerboard is blurred");
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "overlayEdge"})).apply(&mut d, w, h, &space(w, h));
fx("blur.smart", json!({"blurMode": "overlayEdge", "radius": 1})).apply(&mut d, w, h, &space(w, h));
for y in 0..h {
for x in 0..w {
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
@@ -726,6 +729,87 @@ fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
}
}
/// The edges #2154 drew at any radius: a pixel is on an edge when one of its four nearest pixels
/// (transparent beyond the raster) differs from it by more than `t` levels in a channel and its
/// channels add up to at least that pixel's.
fn four_neighbor_edges(src: &[u8], w: usize, h: usize, t: u8) -> Vec<bool> {
let px = |x: isize, y: isize| if x < 0 || y < 0 || x as usize >= w || y as usize >= h { [0; 4] } else { at(src, w, x as usize, y as usize) };
let sum = |p: [u8; 4]| p.iter().map(|v| u32::from(*v)).sum::<u32>();
let mut edges = Vec::new();
for y in 0..h as isize {
for x in 0..w as isize {
let c = px(x, y);
edges.push([(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
let n = px(x + dx, y + dy);
c.iter().zip(&n).any(|(a, b)| a.abs_diff(*b) > t) && sum(c) >= sum(n)
}));
}
}
edges
}
#[test]
fn smart_blur_edges_at_radius_one_are_the_four_neighbor_edges() {
let (w, h) = (48, 40);
let src = image(w, h, |x, y| {
let a = if x > 4 && y < 36 { 255 } else { 128 };
[((x * 5 + y) as u8).min(a), ((y * 6) as u8).min(a), (((x * y) % 97) as u8).min(a), a]
});
for t in [5, 10, 25, 60] {
let edges = four_neighbor_edges(&src, w, h, t);
assert!(edges.contains(&true) && edges.contains(&false), "threshold {t}");
let want: Vec<u8> = src.as_chunks::<4>().0.iter().zip(&edges).flat_map(|(p, e)| if *e { [p[3]; 4] } else { [0, 0, 0, p[3]] }).collect();
for radius in [0.1, 0.5, 1.0] {
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": radius, "threshold": t})).apply(&mut d, w, h, &space(w, h));
assert_eq!(d, want, "threshold {t}, radius {radius}");
}
}
}
#[test]
fn smart_blur_radius_widens_the_edge_search() {
// Gray 60 left of x = 15 and gray 200 from it on: a step of 140 levels.
let (w, h) = (30, 16);
let src = image(w, h, |x, _| grey(if x < 15 { 60 } else { 200 }));
for radius in [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5] {
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": radius})).apply(&mut d, w, h, &space(w, h));
// The pixels within `radius` of a darker one: the step's first `r` columns on its lighter
// side, and a band `r` wide along the raster's border (transparency is beyond it). Up to
// radius 4.5 the search reads no blurred copy: at 4 and 4.5 the medium quality's 7 samples
// across are 4/3 pixels apart, under the 2 pixels a box blur of radius 1 needs.
let r = radius as usize;
let want =
image(w, h, |x, y| if x < r || y < r || x >= w - r || y >= h - r || (15..15 + r).contains(&x) { [255; 4] } else { [0, 0, 0, 255] });
assert_eq!(d, want, "radius {radius}");
}
// Overlay Edge draws the same edges over the blur.
let mut normal = src.clone();
fx("blur.smart", json!({"radius": 3})).apply(&mut normal, w, h, &space(w, h));
let mut over = src.clone();
fx("blur.smart", json!({"blurMode": "overlayEdge", "radius": 3})).apply(&mut over, w, h, &space(w, h));
assert_eq!(at(&over, w, 17, 8), [255; 4]);
assert_eq!(at(&over, w, 18, 8), at(&normal, w, 18, 8));
// A wide radius reads a grid of samples, also through a blurred copy, and finds the step from
// the radius away, on its lighter side only.
let (w, h) = (260, 100);
let src = image(w, h, |x, _| grey(if x < 100 { 60 } else { 200 }));
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut d, w, h, &space(w, h));
for (x, edge) in [(100, true), (130, true), (139, true), (170, false), (99, false), (70, false)] {
assert_eq!(at(&d, w, x, 50)[0] == 255, edge, "({x}, 50)");
}
// A dark band narrower than the gaps between the samples is found through the blurred copy:
// at x = 120 the samples fall on x = 107, 93 and 80, all light.
let src = image(w, h, |x, _| grey(if (100..104).contains(&x) { 60 } else { 200 }));
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut d, w, h, &space(w, h));
for (x, edge) in [(104, true), (120, true), (102, false)] {
assert_eq!(at(&d, w, x, 50)[0] == 255, edge, "band ({x}, 50)");
}
}
#[test]
fn unsharp_mask_overshoots_at_edges() {
let (w, h) = (40, 10);
@@ -1893,3 +1977,272 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
assert_eq!(shifted, one, "{id}");
}
}
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.halftonePattern", p).apply(&mut out, w, h, s);
out
}
/// The smallest period of `values` (their length when they don't repeat).
fn period(values: &[u8]) -> usize {
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
}
#[test]
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
assert_eq!(
fx("sketch.halftonePattern", json!({"size": 1e9, "contrast": -3, "patternType": "LINE"})),
PixelFx::HalftonePattern { size: 12.0, contrast: 0.0, kind: HalftonePattern::Line }
);
assert_eq!(
fx("sketch.halftonePattern", json!({"size": 0, "contrast": 1e9, "patternType": "circle"})),
PixelFx::HalftonePattern { size: 1.0, contrast: 50.0, kind: HalftonePattern::Circle }
);
for junk in [json!({"patternType": 2}), json!({"patternType": "nonsense"})] {
assert_eq!(fx("sketch.halftonePattern", junk), fx("sketch.halftonePattern", json!({})));
}
let doc = crate::effect_info("sketch.halftonePattern").unwrap().params;
let kinds: Vec<HalftonePattern> = HALFTONE_TYPES.iter().map(|(_, v)| HalftonePattern::parse(v)).collect();
assert_eq!(kinds, [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]);
assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
let f = fx("sketch.halftonePattern", json!({"size": 12}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
}
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
#[test]
fn halftone_line_period_is_the_size_at_any_resolution() {
let p = json!({"size": 4, "contrast": 50, "patternType": "line"});
let (w, h) = (48, 48);
let out = halftone(p.clone(), &image(w, h, |_, _| grey(128)), w, h, &space(w, h));
let column: Vec<u8> = (0..h).map(|y| at(&out, w, 0, y)[0]).collect();
assert!(column.contains(&0) && column.contains(&255), "{column:?}");
assert_eq!(period(&column), 4, "{column:?}");
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
let out = halftone(p, &image(2 * w, 2 * h, |_, _| grey(128)), 2 * w, 2 * h, &fine);
let column: Vec<u8> = (0..2 * h).map(|y| at(&out, 2 * w, 0, y)[0]).collect();
assert_eq!(period(&column), 8, "{column:?}");
}
/// Circle draws rings round the object's centre, Line rows that don't change along them, and
/// Dot neither.
#[test]
fn halftone_pattern_type_changes_the_pattern() {
let (w, h) = (64, 64);
let src = image(w, h, |_, _| grey(100));
// The centre on a pixel's centre: pixel (32 + dx, 32 + dy) lies at (dx, dy) from it.
let s = PixelSpace { center: Point::new(32.5, 32.5), ..space(w, h) };
let draw = |kind: &str| halftone(json!({"size": 8, "contrast": 50, "patternType": kind}), &src, w, h, &s);
let (circle, dot, line) = (draw("circle"), draw("dot"), draw("line"));
assert!(circle != dot && dot != line && line != circle);
// Pixels at the same distance from the centre in different directions.
let same_radius: [&[(i64, i64)]; 6] = [
&[(5, 0), (3, 4), (-4, 3), (0, -5), (-3, -4)],
&[(10, 0), (6, 8), (-8, -6), (0, 10)],
&[(13, 0), (5, 12), (-12, 5), (0, -13)],
&[(15, 0), (9, 12), (-12, 9)],
&[(17, 0), (8, 15), (-15, -8)],
&[(25, 0), (7, 24), (-15, 20), (-20, -15), (24, -7)],
];
let value = |d: &[u8], (dx, dy): (i64, i64)| at(d, w, (32 + dx) as usize, (32 + dy) as usize)[0];
let radial = |d: &[u8]| same_radius.iter().all(|ring| ring.iter().all(|q| value(d, *q) == value(d, ring[0])));
assert!(radial(&circle), "Circle is the same all round");
assert!(!radial(&dot) && !radial(&line));
for y in 0..h {
assert!((0..w).all(|x| at(&line, w, x, y) == at(&line, w, 0, y)), "row {y} of Line is uniform");
}
}
#[test]
fn halftone_contrast_sharpens_the_screen() {
let (w, h) = (64, 32);
let src = image(w, h, |x, _| grey((x * 4) as u8));
let mid = |d: &[u8]| share(d, |p| p[0] != 0 && p[0] != 255);
let soft = halftone(json!({"size": 6, "contrast": 0}), &src, w, h, &space(w, h));
let hard = halftone(json!({"size": 6, "contrast": 50}), &src, w, h, &space(w, h));
assert!(mid(&soft) > 0.3, "{}", mid(&soft));
assert_eq!(mid(&hard), 0.0);
}
/// Halftone Pattern redraws in black ink on white paper, whatever the object's colours, and
/// keeps its coverage.
#[test]
fn halftone_draws_in_black_and_white_only() {
let (w, h) = (48, 48);
let src = image(w, h, |x, y| {
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
let a: u16 = if y < 24 { 255 } else { 128 };
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
});
for kind in ["circle", "dot", "line"] {
let out = halftone(json!({"size": 6, "contrast": 50, "patternType": kind}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "{kind}: coverage kept");
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{kind}: {p:?} from {q:?}");
}
assert!(share(&out, |p| p[0] == 0) > 0.1 && share(&out, |p| p[0] == p[3]) > 0.1, "{kind}: ink and paper");
let soft = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &src, w, h, &space(w, h));
assert!(soft.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{kind}: greys only");
for (tone, expect) in [(0, 0), (255, 255)] {
let out = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &image(w, h, |_, _| grey(tone)), w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == expect), "{kind}: {tone} stays {expect}");
}
}
}
/// The distinct colours of the covered pixels.
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
}
/// A left-to-right rainbow ramp, half covered at its bottom rows.
fn ramp(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, y| {
let t = x * 255 / (w - 1);
let a = if y + 4 >= h { 128 } else { 255 };
let c = [t, 255 - t, (t * 3) % 256].map(|v| (v * a / 255) as u8);
[c[0], c[1], c[2], a as u8]
})
}
#[test]
fn artistic_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
assert_eq!(
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 }
);
assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 });
assert_eq!(
fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})),
PixelFx::PosterEdges { thickness: 10.0, intensity: 0.0, posterization: 6.0 }
);
let PixelFx::NeonGlow { size, brightness, colour } = fx("artistic.neonGlow", json!({})) else { panic!() };
assert_eq!((size, brightness), (5.0, 15.0));
assert!((colour[0] - 0.3).abs() < 0.01 && (colour[1] - 0.65).abs() < 0.01 && colour[2] == 1.0, "{colour:?}");
assert_eq!(
fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})),
PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] }
);
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] {
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
}
}
#[test]
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
let (w, h) = (64, 24);
let src = ramp(w, h);
for levels in [2, 3, 5, 8] {
let mut out = src.clone();
fx("artistic.cutout", json!({"levels": levels})).apply(&mut out, w, h, &space(w, h));
let opaque: std::collections::BTreeSet<_> = colours(&out).into_iter().filter(|p| p[3] == 255).collect();
assert!(opaque.len() <= levels * levels * levels && opaque.len() >= 2, "{levels} levels: {} colours", opaque.len());
assert!(opaque.len() < 64, "flat pieces, not a ramp: {levels} levels, {} colours", opaque.len());
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
}
}
#[test]
fn cutout_simplicity_smooths_the_pieces_edges() {
// A noisy grey: simpler edges join the speckles into fewer changes between neighbours.
let (w, h) = (48, 48);
let src = image(w, h, |x, y| grey(if (x * 7 + y * 13) % 5 < 2 { 60 } else { 190 }));
let changes = |simplicity: f64| {
let mut out = src.clone();
fx("artistic.cutout", json!({"levels": 2, "edgeSimplicity": simplicity, "edgeFidelity": 1})).apply(&mut out, w, h, &space(w, h));
out.as_chunks::<4>().0.windows(2).filter(|p| p[0] != p[1]).count()
};
assert!(changes(10.0) * 4 < changes(0.0), "{} vs {}", changes(10.0), changes(0.0));
}
#[test]
fn poster_edges_draw_black_lines_on_edges_and_posterize_the_rest() {
let (w, h) = (40, 20);
// Two flat halves: a line where they meet, posterized colour away from it.
let src = image(w, h, |x, _| if x < w / 2 { [200, 90, 30, 255] } else { [30, 120, 210, 255] });
let mut out = src.clone();
fx("artistic.posterEdges", json!({"edgeThickness": 2, "edgeIntensity": 10, "posterization": 0})).apply(&mut out, w, h, &space(w, h));
let line = at(&out, w, w / 2, h / 2);
assert!(line[0] < 40 && line[1] < 40 && line[2] < 40, "a dark line on the edge: {line:?}");
// 2 levels per channel: every far pixel is 0 or 255 in each channel.
for x in [3, w - 4] {
let p = at(&out, w, x, h / 2);
assert!(p[..3].iter().all(|v| *v == 0 || *v == 255), "{x}: {p:?}");
}
// Inside a flat colour, away from the object's outline, there are no lines: it only posterizes.
let mut flat = image(w, h, |_, _| [200, 90, 30, 255]);
fx("artistic.posterEdges", json!({"edgeIntensity": 10, "posterization": 6})).apply(&mut flat, w, h, &space(w, h));
let inside: std::collections::BTreeSet<_> = (6..h - 6).flat_map(|y| (6..w - 6).map(move |x| (x, y))).map(|(x, y)| at(&flat, w, x, y)).collect();
assert_eq!(inside.len(), 1, "{inside:?}");
assert!(at(&flat, w, 0, h / 2)[0] < 60, "the outline takes a line");
}
#[test]
fn poster_edges_thickness_widens_the_lines() {
let (w, h) = (60, 10);
let src = image(w, h, |x, _| grey(if x < w / 2 { 30 } else { 230 }));
let dark = |thickness: f64| {
let mut out = src.clone();
fx("artistic.posterEdges", json!({"edgeThickness": thickness, "edgeIntensity": 10, "posterization": 6})).apply(&mut out, w, h, &space(w, h));
(0..w).filter(|x| at(&out, w, *x, h / 2)[0] < 20 && (*x >= w / 2 || at(&src, w, *x, h / 2)[0] > 20)).count()
};
assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0));
}
#[test]
fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() {
let (w, h) = (40, 20);
let src = image(w, h, |x, _| grey(if x < w / 2 { 20 } else { 235 }));
let run = |size: f64| {
let mut out = src.clone();
fx("artistic.neonGlow", json!({"glowSize": size, "glowBrightness": 40, "color": "#ff0000"})).apply(&mut out, w, h, &space(w, h));
(at(&out, w, 3, h / 2), at(&out, w, w - 4, h / 2))
};
// Red glow: the red channel rises above green and blue where it glows.
let red = |p: [u8; 4]| i32::from(p[0]) - i32::from(p[1]);
let (dark, light) = run(8.0);
assert!(red(light) > 60 && red(light) > red(dark) + 40, "light areas glow: {dark:?} {light:?}");
let (dark, light) = run(-8.0);
assert!(red(dark) > 60 && red(dark) > red(light) + 40, "dark areas glow: {dark:?} {light:?}");
// No brightness: dimmed greys only.
let mut out = src.clone();
fx("artistic.neonGlow", json!({"glowBrightness": 0})).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));
}
#[test]
fn artistic_filters_look_the_same_at_any_resolution() {
// The same art at 1 and at 2 pixels per point: the filter at the coarse resolution matches
// the fine result sampled at the same points.
let (w, h) = (48, 32);
let art = |x: f64, y: f64| grey(if (x - 24.0).hypot(y - 16.0) < 10.0 { 230 } else { 40 });
let coarse = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
let fine = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let mut half = space(2 * w, 2 * h);
half.to_doc = Affine::scale(0.5);
half.px = 0.5;
half.center = Point::new(w as f64 / 2.0, h as f64 / 2.0);
for (id, p) in [
("artistic.cutout", json!({"edgeSimplicity": 6})),
("artistic.neonGlow", json!({"glowSize": 12, "glowBrightness": 30})),
("artistic.posterEdges", json!({"edgeThickness": 6, "edgeIntensity": 6})),
] {
let (mut a, mut b) = (coarse.clone(), fine.clone());
fx(id, p.clone()).apply(&mut a, w, h, &space(w, h));
fx(id, p).apply(&mut b, 2 * w, 2 * h, &half);
let diff: f64 = (0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.map(|(x, y)| {
let (p, q) = (at(&a, w, x, y), at(&b, 2 * w, 2 * x, 2 * y));
(0..3).map(|k| f64::from(p[k].abs_diff(q[k]))).sum::<f64>() / 3.0
})
.sum::<f64>()
/ (w * h) as f64;
assert!(diff < 12.0, "{id}: mean difference {diff}");
}
}
+82 -8
View File
@@ -5,7 +5,9 @@
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints.
use std::sync::Arc;
@@ -30,6 +32,25 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do.
pub fn has_gradient_copies(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
&& geometry_effects(n)
.iter()
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
/// paints ([`outline_text`], which leaves inline graphics out).
pub fn has_item_geometry(n: &Node) -> bool {
let NodeKind::Text(t) = &n.kind else { return false };
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
}
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
/// path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
@@ -117,6 +138,16 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
a.transform(*xf, false);
Node::group(n.id, vec![Arc::new(a)])
}
// The object as the one piece the copies of its Transform effect are made from.
_ if has_gradient_copies(n) => {
let mut one = n.clone();
one.opacity = 1.0;
one.blend = Default::default();
one.isolate = false;
one.extras_mut().mask = None;
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
_ => return None,
};
@@ -155,20 +186,27 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
/// perspective, or has no art (a symbol instance without `symbol_art`).
/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
/// symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
return None;
}
as_art(n, symbol_art)
}
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
/// Appearance: [`reshape`]d when it has
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
/// when it has neither.
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
if has_gradient_copies(n) {
return as_art(n, symbol_art);
}
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
return None;
@@ -256,13 +294,21 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, &params, seed));
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// A gradient paints each copy by itself, not the one piece the copies make together.
if let Some((m, copies)) = transform
&& copies > 0
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
{
copy_with_gradients(leaf, &path, m, copies);
return;
}
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
set_geometry(leaf, out, rule);
});
if e.id == "distort.transform" {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
if let Some((m, copies)) = transform {
transform_placed(art, m, copies);
}
}
@@ -271,6 +317,34 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
}
}
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each
/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would.
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) {
let each = path.anchor_count();
let mut anchors = each;
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
let mut xf = Affine::IDENTITY;
for _ in 0..copies {
anchors = anchors.saturating_add(each);
if anchors > crate::distort::MAX_COPY_ANCHORS {
break;
}
xf *= m;
let mut copy = leaf.clone();
copy.transform(xf, vectorcraft_doc::Scaling::default());
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
}
// The copies become nodes of their own under a group with the object's id and name.
let mut g = Node::group(leaf.id, placements);
g.name = leaf.name.clone();
*leaf = g;
}
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
/// order. A group inside one of them is part of it.
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
+37
View File
@@ -510,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
}
}
/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and
/// valley is a smooth point whose handles run parallel to the line, and the curve between them
/// follows a cosine, even where the ridges are much taller than they are apart.
#[test]
fn zigzag_smooth_is_a_wave() {
use vectorcraft_geom::kurbo::ParamCurve;
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
for size in [3.0, 30.0] {
let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line);
let sp = &out.subpaths[0];
assert_eq!(sp.anchors.len(), 11);
for a in &sp.anchors {
assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}");
}
for i in 0..sp.segment_count() {
let c = sp.segment(i);
for k in 0..=20 {
let q = c.eval(k as f64 / 20.0);
let want = -size * (std::f64::consts::PI * q.x / 10.0).cos();
assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})");
}
}
}
}
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
/// turn all the way round.
#[test]
@@ -572,6 +597,18 @@ fn outline_stroke_uses_context_stroke() {
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
}
#[test]
fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
let info = effect_info("path.outlineObject").unwrap();
assert_eq!((info.label, info.menu, &info.defaults, info.raster), ("Outline Object", &["Effect", "Path"][..], &json!({}), false));
assert!(is_geometry("path.outlineObject"));
assert_eq!(run("path.outlineObject", json!({}), &square()), square());
// The effects after it reshape the same geometry.
let b = square().bounds().unwrap();
let offset = fx("path.offsetPath", json!({"offset": 5}));
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
}
#[test]
fn convert_to_shape_sizes() {
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
+1 -1
View File
@@ -80,7 +80,7 @@ fn clean_name(name: &str) -> Option<String> {
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
/// (sublayers unfolded). Layers alone have nothing to collect.
fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = doc.node(id) else { return };
if n.is_layer() {
if let Some(kids) = n.children() {
+175 -29
View File
@@ -11,11 +11,12 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, Brush};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::swatch::names_param;
use super::*;
use crate::EngineError;
@@ -65,8 +66,16 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_new
),
cmd!("brush.delete", "Delete Brush", [], None, "{name} delete a brush; strokes using it become plain strokes", has_doc, brush_delete),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} → {name}", has_doc, brush_duplicate),
cmd!(
"brush.delete",
"Delete Brush",
[],
None,
"{name} | {names: [name…]} delete brushes; strokes using them become plain strokes",
has_doc,
brush_delete
),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
cmd!(
"brush.options",
"Brush Options…",
@@ -95,16 +104,25 @@ pub fn specs() -> Vec<CommandSpec> {
brush_freehand
),
// ----- symbols -----
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8}], current}", has_doc, symbol_list),
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list),
cmd!(
"symbol.new",
"New Symbol…",
[],
None,
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration)} make a symbol from the selection and replace it with an instance → {name, id, registration}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
has_selection,
symbol_new
),
cmd!(
"symbol.options",
"Symbol Options…",
[],
None,
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}",
has_doc,
symbol_options
),
cmd!(
"symbol.place",
"Place Symbol Instance",
@@ -330,10 +348,10 @@ fn merge(base: &mut Value, patch: &Value) {
}
}
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, rename: Option<(String, Option<String>)>) -> Result<()> {
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
s.edit(label, |d, _| {
brush::store(d, &lib);
if let Some((from, to)) = rename {
for (from, to) in renames {
let ids: Vec<NodeId> = {
let mut v = vec![];
d.walk(|n| {
@@ -393,7 +411,7 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
v[k] = serde_json::to_value(&art).unwrap_or_default();
}
lib.push(parse_brush(C, v)?);
save_library(s, "New Brush", lib, None)?;
save_library(s, "New Brush", lib, &[])?;
Ok(json!({ "name": name }))
}
@@ -405,26 +423,35 @@ fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
}
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.delete")?.to_string();
const C: &str = "brush.delete";
let names = names_param(p, C)?;
let mut lib = brush::library(&s.doc()?.doc);
let n = lib.len();
lib.retain(|b| b.name != name);
if lib.len() == n {
return Err(bad("brush.delete", format!("no brush named `{name}`")));
if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) {
return Err(bad(C, format!("no brush named `{name}`")));
}
save_library(s, "Delete Brush", lib, Some((name, None)))?;
lib.retain(|b| !names.contains(&b.name));
let renames: Vec<(String, Option<String>)> = names.into_iter().map(|n| (n, None)).collect();
save_library(s, "Delete Brush", lib, &renames)?;
ok()
}
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
/// without `names`, names the copy.
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.duplicate")?;
let mut lib = brush::library(&s.doc()?.doc);
let mut b = lib.iter().find(|b| b.name == name).cloned().ok_or_else(|| bad("brush.duplicate", format!("no brush named `{name}`")))?;
b.name = brush::unique_name(&lib, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let out = b.name.clone();
lib.push(b);
save_library(s, "Duplicate Brush", lib, None)?;
Ok(json!({ "name": out }))
const C: &str = "brush.duplicate";
let names = names_param(p, C)?;
let before = brush::library(&s.doc()?.doc);
let mut lib = before.clone();
let mut out = vec![];
for name in &names {
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
b.name = brush::unique_name(&lib, &new_name);
out.push(b.name.clone());
lib.push(b);
}
save_library(s, "Duplicate Brush", lib, &[])?;
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
}
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
@@ -449,7 +476,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
lib[i] = parse_brush(C, v)?;
let out = lib[i].name.clone();
let rename = new_name.map(|nn| (name, Some(nn)));
save_library(s, "Brush Options", lib, rename)?;
save_library(s, "Brush Options", lib, rename.as_slice())?;
Ok(json!({ "name": out }))
}
@@ -517,10 +544,57 @@ fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
}
fn registration_param(p: &Value, cmd: &str) -> Result<SymbolRegistration> {
fn registration_param(p: &Value, cmd: &str) -> Result<Option<SymbolRegistration>> {
match p.get("registration") {
None => Ok(SymbolRegistration::CENTER),
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).ok_or_else(|| bad(cmd, "registration must be 0..8")),
None => Ok(None),
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")),
}
}
fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
match str_param(p, "exportType") {
None => Ok(None),
Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))),
}
}
/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural
/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it
/// on with guides `g` (the symbol's current ones, else the centre third, when none are given).
fn nine_slice_param(p: &Value, cmd: &str, current: Option<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
let on = match p.get("nineSlice") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?),
};
let guides =
match p.get("nineSliceGuides") {
None | Some(Value::Null) => None,
Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| {
bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1")
})?),
};
Ok(match (on, guides) {
(Some(false), _) => Some(None),
(None, None) => None,
(_, Some(g)) => Some(Some(g)),
(Some(true), None) => Some(Some(current.unwrap_or_default())),
})
}
fn nine_slice_json(d: &Document, name: &str) -> Value {
d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value)
}
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
if from == to {
return;
}
let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned();
set_natural_size(d, from, None);
if let Some(v) = size
&& let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64))
{
set_natural_size(d, to, Some((w, h)));
}
}
@@ -566,6 +640,8 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
"instances": instances_of(d, Some(&sym.name)).len(),
"size": [w, h],
"registration": d.symbol_registration(&sym.name).0,
"exportType": d.symbol_export_type(&sym.name).id(),
"nineSlice": nine_slice_json(d, &sym.name),
})
})
.collect();
@@ -589,11 +665,14 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
}
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.new";
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (art, size, c) = normalize(art)?;
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let reg = registration_param(p, "symbol.new")?;
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
let export = export_type_param(p, C)?.unwrap_or_default();
let nine = nine_slice_param(p, C, None)?.flatten();
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
let id = s.edit("New Symbol", |d, sel| {
@@ -601,12 +680,73 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
set_natural_size(d, &name, Some(size));
d.set_symbol_registration(&name, reg);
d.set_symbol_export_type(&name, export);
d.set_symbol_nine_slice(&name, nine);
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let id = replace_with_instance(d, &ids, &name, place)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "name": name, "id": id.0, "registration": reg.0 }))
Ok(
json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }),
)
}
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.options";
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
let export = export_type_param(p, C)?;
let reg = registration_param(p, C)?;
let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?;
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() {
return Err(bad(C, "pass newName, registration, exportType and/or nineSlice"));
}
if let Some(ref n) = new_name
&& n != &name
&& s.doc()?.doc.symbols.iter().any(|x| x.name == *n)
{
return Err(bad(C, format!("a symbol named `{n}` already exists")));
}
let final_name = new_name.clone().unwrap_or_else(|| name.clone());
s.edit("Symbol Options", |d, _| {
if let Some(ref n) = new_name
&& n != &name
{
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
sym.name = n.clone();
}
for id in instances_of(d, Some(&name)) {
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) {
*symbol = n.clone();
}
}
rename_natural_size(d, &name, n);
d.rename_symbol_registration(&name, n);
d.rename_symbol_export_type(&name, n);
d.rename_symbol_nine_slice(&name, n);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
}
}
if let Some(r) = reg {
d.set_symbol_registration(&final_name, r);
}
if let Some(kind) = export {
d.set_symbol_export_type(&final_name, kind);
}
if let Some(g) = nine {
d.set_symbol_nine_slice(&final_name, g);
}
Ok(())
})?;
let d = &s.doc()?.doc;
Ok(json!({
"name": final_name,
"registration": d.symbol_registration(&final_name).0,
"exportType": d.symbol_export_type(&final_name).id(),
"nineSlice": nine_slice_json(d, &final_name),
}))
}
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
@@ -763,6 +903,8 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
d.symbols.retain(|x| x.name != name);
set_natural_size(d, &name, None);
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
d.clear_symbol_export_type(&name);
d.set_symbol_nine_slice(&name, None);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.remove(CURRENT_SYMBOL);
}
@@ -779,14 +921,18 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let export = d.symbol_export_type(&name);
let nine = d.symbol_nine_slice(&name);
s.edit("Duplicate Symbol", |d, _| {
let art = d.reid(&sym.art);
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
set_natural_size(d, &new, Some(size));
d.set_symbol_registration(&new, reg);
d.set_symbol_export_type(&new, export);
d.set_symbol_nine_slice(&new, nine);
Ok(())
})?;
Ok(json!({ "name": new }))
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }))
}
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
+168 -10
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
@@ -88,6 +88,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection_or_ids,
lp_expand
),
cmd!(
"livePaint.gapOptions",
"Gap Options",
[],
None,
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
has_selection_or_ids,
lp_gap_options
),
cmd!(
"livePaint.fill",
"Live Paint Bucket",
@@ -111,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Info",
[],
None,
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
has_doc,
lp_info
),
@@ -383,10 +392,11 @@ fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
}
/// Planar map children (sources group, faces, edges) for `sources`.
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the
/// fill, stroke and stroke width of each.
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<(Paint, Paint, f64)>) {
let mut lv = vec![];
for n in &sources {
for n in sources {
b::leaf_shapes(n, &mut lv);
}
let mut shapes = vec![];
@@ -400,6 +410,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
}
}
(shapes, styles)
}
/// Planar map children (sources group, faces, edges) for `sources`; with `max_gap` (Gap
/// Detection), paint stops at the gaps up to that wide.
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
let (shapes, styles) = source_shapes(&sources);
if shapes.is_empty() {
return Err(EngineError::Other("Live Paint: select paths or compound paths".into()));
}
@@ -410,7 +427,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
src_group.visible = false;
children.push(Arc::new(src_group));
// Every path is an edge, open ones included; faces are the areas they enclose.
let (regs, edges) = po::live_paint(&shapes);
let (regs, edges) = match max_gap {
Some(gap) => {
let (regs, edges, _) = po::live_paint_with_gaps(&shapes, gap);
(regs, edges)
}
None => po::live_paint(&shapes),
};
if regs.is_empty() && edges.is_empty() {
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
}
@@ -433,6 +456,51 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
Ok((children, nfaces, nedges))
}
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
/// appearance, none included, its transparency and the rest) of the old face its inside point is
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
/// keep the paint of their sources.
fn repaint_from(g: &mut Node, old: &Node) {
/// How far an edge's middle may be from the old edge it lies on.
const ON_EDGE: f64 = 1e-3;
let Some(ch) = g.children_mut() else { return };
for c in ch.iter_mut() {
let Some(pd) = c.path_data() else { continue };
let was = match c.name.as_deref() {
Some(FACE_NAME) => po::interior_point(pd).and_then(|pt| face_at(old, pt)),
Some(EDGE_NAME) => pd
.subpaths
.first()
.filter(|sp| sp.segment_count() > 0)
.and_then(|sp| edge_near(old, kurbo::ParamCurve::eval(&sp.segment(0), 0.5), ON_EDGE)),
_ => None,
};
if let Some(was) = was {
let mut n = (**was).clone();
// Gradients fitted to the old outline keep that fit on the new one.
n.pin_gradients();
n.id = c.id;
n.kind = c.kind.clone();
n.bbox_angle = c.bbox_angle;
*c = Arc::new(n);
}
}
}
/// `options` as stored on a Live Paint group: none for the defaults.
fn stored_gap_options(options: GapOptions) -> Option<Box<GapOptions>> {
(options != GapOptions::default()).then(|| Box::new(options))
}
/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most
/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another
/// group's is on; else the bottom-most group's.
fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
let Some(&bottom) = groups.first() else { return GapOptions::default() };
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
}
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
let mut sources = vec![];
@@ -440,7 +508,7 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
sources.extend(source_nodes(n));
}
let (children, nf, ne) = build_children(d, sources)?;
let (children, nf, ne) = build_children(d, sources, None)?;
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = Some(LIVE_PAINT_NAME.into());
@@ -484,11 +552,15 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
}
}
let (children, nf, ne) = build_children(d, sources)?;
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
let options = merged_gap_options(&groups);
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
let mut g = Node::group(target, children);
g.name = old.name.clone();
g.opacity = old.opacity;
g.blend = old.blend;
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
@@ -553,7 +625,11 @@ 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.extras_mut().trace = None;
{
let mut e = n.extras_mut();
e.trace = None;
e.live_paint_gaps = None;
}
if let Some(ch) = n.children_mut() {
ch.retain(|c| match c.name.as_deref() {
Some(SOURCES_NAME) => false,
@@ -569,6 +645,81 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&targets) }))
}
/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to).
fn gaps_json(gaps: &[[Point; 2]]) -> Value {
json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::<Vec<_>>())
}
/// A group's Gap Options as `livePaint.info` gives them.
fn gap_options_json(o: GapOptions) -> Value {
let [r, g, b] = o.color;
let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id));
json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") })
}
/// The gaps up to `options`' size wide among a Live Paint group's sources.
fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> {
po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points())
}
fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.gapOptions";
let detect = match p.get("detect").filter(|v| !v.is_null()) {
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?),
None => None,
};
let size =
match p.get("size").filter(|v| !v.is_null()) {
Some(v) => Some(GapSize::parse(v).ok_or_else(|| {
bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX))
})?),
None => None,
};
let color = match p.get("color").filter(|v| !v.is_null()) {
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
None => None,
};
let close = bool_or(p, "closeGaps", false);
let targets = live_targets(s, p)?;
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
let mut changed = Beside::replacing(d, &targets);
for g in &targets {
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
let mut options = old.gap_options();
options.detect = detect.unwrap_or(options.detect);
options.size = size.unwrap_or(options.size);
options.color = color.unwrap_or(options.color);
let mut sources = source_nodes(&old);
if close {
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
let line = PathData::single(SubPath::polyline(&gap, false));
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
closed += 1;
}
}
let mut n = old.clone();
// The faces change only with what paint stops at, or with new paths.
if close || options.max_gap() != old.gap_options().max_gap() {
let (children, _, _) = build_children(d, sources, options.max_gap())?;
if let Some(ch) = n.children_mut() {
*ch = children;
}
repaint_from(&mut n, &old);
}
n.extras_mut().live_paint_gaps = stored_gap_options(options);
faces += b::faces(&n).len();
gaps.extend(open_gaps(&n, options));
changed.put(d, *g, vec![n])?;
}
changed.finish(d)?;
sel.set(targets.iter().copied());
Ok((faces, gaps, closed))
})?;
Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed }))
}
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.fill";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
@@ -637,7 +788,14 @@ fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
.collect();
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
Ok(json!({ "faces": faces, "edges": edges, "sources": sources }))
let options = n.gap_options();
Ok(json!({
"faces": faces,
"edges": edges,
"sources": sources,
"gapOptions": gap_options_json(options),
"gaps": gaps_json(&open_gaps(n, options)),
}))
}
// ---------- Image Trace ----------
+15 -9
View File
@@ -80,22 +80,22 @@ pub struct Clipboard {
pub para_styles: Vec<TextStyleDef>,
/// Brushes the objects' strokes use.
pub brushes: Vec<Brush>,
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
/// Artboards keeps their offset to it.
/// The artboard the objects were copied from (none when they lie on no artboard; the first of
/// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it.
pub source_artboard: Option<Rect>,
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
pub source_layers: Vec<Option<String>>,
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
/// own resources as they are now.
pub source_doc: Option<u64>,
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
/// adds a copy of it with the art.
pub artboard: Option<vectorcraft_doc::Artboard>,
/// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art.
/// Pasting adds a copy of each with the art.
pub artboards: Vec<vectorcraft_doc::Artboard>,
}
impl Clipboard {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.artboard.is_none()
self.nodes.is_empty() && self.artboards.is_empty()
}
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
@@ -109,7 +109,9 @@ impl Clipboard {
// The nearest enclosing layer (or sublayer).
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
}
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
// Symbol instances are measured by their art.
let symbols = d.symbol_boxes();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b));
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
let board = bounds.and_then(|b| d.artboard_at(b.center()));
Self {
@@ -127,9 +129,13 @@ impl Clipboard {
Self::gather(src, nodes)
}
/// Union of the objects' visual bounds.
/// Union of the objects' visual bounds, with symbol instances measured by the art of the
/// symbols the clipboard holds.
pub fn bounds(&self) -> Option<Rect> {
self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b))
let mut d = Document::new(100.0, 100.0);
d.symbols = self.symbols.clone();
let symbols = d.symbol_boxes();
self.nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))
}
/// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none
+3 -1
View File
@@ -81,6 +81,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
// reads SVGZ data.
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
"pdf" => (PDF, PDF),
"ps" => (EPS, EPS),
"eps" => (EPS, EPS),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
@@ -418,7 +419,8 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importEps";
let bytes = data_param(p, C)?;
if !vectorcraft_pdf::is_postscript(&bytes) {
// Programs whose header is only `%!` are read too, as they are when a `.ps` or `.eps` file opens.
if !bytes.starts_with(b"%!") && !vectorcraft_pdf::is_postscript(&bytes) {
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
}
import_picture(s, p, C, "Clipboard.eps", &bytes)
+36 -4
View File
@@ -39,10 +39,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Color Profile…",
[],
None,
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
"{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}",
always,
load_profile
),
cmd!(
"color.removeProfile",
"Remove Color Profile…",
[],
None,
"{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}",
always,
remove_profile
),
cmd!(
"edit.assignProfile",
"Assign Profile…",
@@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
}
#[cfg(not(target_arch = "wasm32"))]
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.loadProfile";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
crate::file_access::check_read(path).map_err(EngineError::Other)?;
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
Ok(json!({"name": info.name, "kind": info.kind}))
let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?;
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
// The bytes are kept, not the path: the file may move or go away.
let remembered = s.store_profile(&info.name, &bytes);
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
}
/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it.
fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.removeProfile";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?;
if info.builtin {
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
}
let working = cms::active_settings();
if working.rgb == name || working.cmyk == name {
return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first")));
}
if !cms::unregister_icc(&info.name) {
return Err(bad(C, format!("no profile named `{name}`")));
}
s.unstore_profile(&info.name);
touch_all(s);
Ok(json!({"removed": info.name}))
}
#[cfg(target_arch = "wasm32")]
+2 -1
View File
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -512,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
}
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
let made = super::typecmd::with_bounds(s, id, made);
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
}
+9 -12
View File
@@ -746,13 +746,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
/// never saved).
/// never saved, [`crate::DocState::change_unsaved`]).
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
let clean = !st.is_dirty();
Arc::make_mut(&mut st.doc).puppet = None;
if clean {
st.mark_saved();
}
st.change_unsaved(|d| Arc::make_mut(d).puppet = None);
}
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1335,7 +1331,8 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
/// in every undo state (the flat view is never an undo step of its own).
/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved
/// state stays saved, [`crate::DocState::change_unsaved`]).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
@@ -1346,14 +1343,14 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
p.editing = false;
}
};
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
clear(&mut st.doc);
if saved {
st.saved_doc = st.doc.clone();
}
st.change_unsaved(clear);
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
clear(&mut e.doc);
e.doc.drop_lookup_cache();
if saved {
st.saved_doc = e.doc.clone();
}
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);
+135 -15
View File
@@ -100,6 +100,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
save_selection
),
cmd!(
"select.update",
"Update Selection",
["Select"],
None,
"{} make the saved selection last recalled (`select.recall`) hold the selected objects instead of its own, in one undo step → {name}",
has_recalled_selection,
update_selection
),
cmd!(
"select.recall",
"Recall Selection",
@@ -135,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release Guides",
["View", "Guides"],
Some("Cmd+Alt+5"),
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
"{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}",
has_doc,
release_guides
),
@@ -144,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Lock Guides",
["View", "Guides"],
Some("Cmd+Alt+;"),
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
has_doc,
lock_guides
),
@@ -342,6 +351,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
if existing.is_none() && saved.len() >= SavedSelection::MAX {
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
}
let mut slots = saved_ids(s)?;
s.edit("Save Selection", |d, _| {
let entry = SavedSelection { name: name.clone(), objects: ids };
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
@@ -350,18 +360,26 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
let st = s.doc_mut()?;
if existing.is_none() {
slots.push(st.recalled_selection.new_id());
}
st.recalled_selection.remember(&st.doc.saved_selections, slots);
Ok(json!({ "name": name }))
}
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.recall";
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}`")))?;
let saved = &s.doc()?.doc.saved_selections;
let i = saved.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
let ids = saved.get(i).map(|x| x.objects.clone()).unwrap_or_default();
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() }))
let st = s.doc_mut()?;
st.recalled_selection.recall(&st.doc.saved_selections, i);
Ok(json!({ "count": st.selection.len() }))
}
/// Recall and Edit Selection… need a document with at least one saved selection.
@@ -370,6 +388,42 @@ fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
}
/// The session ids of the active document's saved selections ([`crate::RecalledSelection`]).
fn saved_ids(s: &mut Session) -> Result<Vec<u64>> {
let st = s.doc_mut()?;
Ok(st.recalled_selection.ids(&st.doc.saved_selections))
}
/// Where the saved selection last recalled is, while the document has it.
fn recalled_index(st: &crate::DocState) -> Option<usize> {
st.recalled_selection.index_in(&st.doc.saved_selections)
}
/// Update Selection needs selected objects and a recalled saved selection the document still has.
fn has_recalled_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
if recalled_index(st).is_some() { Ok(()) } else { Err("no saved selection recalled".into()) }
}
fn update_selection(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "select.update";
let st = s.doc()?;
let i = recalled_index(st).ok_or_else(|| bad(C, "no saved selection recalled"))?;
let ids = st.selection.objects.clone();
let name = st.doc.saved_selections.get(i).map(|x| x.name.clone()).unwrap_or_default();
let slots = saved_ids(s)?;
s.edit("Update Selection", |d, _| {
if let Some(x) = d.saved_selections.get_mut(i) {
x.objects = ids;
}
Ok(())
})?;
let st = s.doc_mut()?;
st.recalled_selection.remember(&st.doc.saved_selections, slots);
Ok(json!({ "name": name }))
}
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.editSaved";
let edits = match p.get("edits").and_then(Value::as_array) {
@@ -396,6 +450,7 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
x.name = n;
}
}
let slots: Vec<u64> = saved_ids(s)?.into_iter().zip(&after).filter(|(_, x)| x.is_some()).map(|(id, _)| id).collect();
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
let mut names = std::collections::HashSet::with_capacity(after.len());
for x in &after {
@@ -408,6 +463,8 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
d.saved_selections = after.into();
Ok(())
})?;
let st = s.doc_mut()?;
st.recalled_selection.remember(&st.doc.saved_selections, slots);
}
ok()
}
@@ -465,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
v
}
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected
/// guide paths and ruler guides, else every guide path, in one undo step.
fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.guides.release";
let (index, ids) = (p.get("index"), p.get("ids"));
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
return Err(EngineError::Other("guides are locked".into()));
}
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
let rulers = match index {
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
None => vec![],
};
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
let d = &s.doc()?.doc;
let mut paths = vec![];
for r in roots {
if let Some(n) = d.node(r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
paths.push(c.id);
}
});
}
}
(rulers, paths)
} else {
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let rulers: Vec<usize> = if s.menu.guides_locked {
vec![]
} else {
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
};
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
};
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
if targets.is_empty() {
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
let mut lines = Vec::with_capacity(rulers.len());
for &i in &rulers {
let Some(g) = st.doc.guides.get(i) else { continue };
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
let Some((a, b)) = span else { continue };
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
Some(l) => Some(l),
None => st.target_parent()?,
};
lines.push((i, parent, a, b));
}
let n = paths.len() + lines.len();
if n == 0 {
return Err(EngineError::Other("Release Guides: no guides".into()));
}
let n = targets.len();
s.edit("Release Guides", |d, sel| {
for id in &targets {
for id in &paths {
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*guide = false;
}
}
sel.set(targets.iter().copied());
let mut made = Vec::with_capacity(lines.len());
for &(_, parent, a, b) in &lines {
let id = d.alloc_id();
let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
made.push(id);
}
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
sel.set(paths.iter().copied().chain(made));
Ok(())
})?;
Ok(json!({ "count": n }))
@@ -489,12 +601,20 @@ fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
s.menu.guides_locked = v;
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
// those go).
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
// ([`Session::select`] lets those go in the active one).
let st = s.doc()?;
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
s.select(|_, sel| sel.guides.clear())?;
}
if v {
let active = s.active;
for (i, st) in s.docs.iter_mut().enumerate() {
if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) {
st.revision += 1;
}
}
}
Ok(json!({ "locked": v }))
}
+4 -4
View File
@@ -121,7 +121,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?,
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
"backgroundContents" => s.background = background_param(v, C)?,
// Read-only values `document.setup` reports.
@@ -223,8 +223,8 @@ fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetric
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
for (key, x) in o {
match key.as_str() {
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, vectorcraft_doc::setup::MAX_SCRIPT)?,
"position" => m.position = percent(&format!("{k}.position"), x, -vectorcraft_doc::setup::MAX_SCRIPT, vectorcraft_doc::setup::MAX_SCRIPT)?,
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
}
}
@@ -251,7 +251,7 @@ pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(O
}
/// A superscript or subscript in `setup`'s proportions.
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
pub(crate) fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
match p {
CharPosition::Normal => CharPosition::Normal,
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
+58 -12
View File
@@ -1,6 +1,7 @@
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
//! blob brush, smooth, path eraser, join).
use std::collections::BTreeSet;
use std::sync::Arc;
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
@@ -12,7 +13,8 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R
use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
use super::create::{add_look, add_node, insert_look};
use super::create::{add_look, insert_look};
use super::newart::NewArt;
use super::pathops::{Beside, remove_each};
use super::*;
use crate::EngineError;
@@ -60,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Anchor Points",
["Object", "Path"],
None,
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}",
has_anchors,
remove_anchors
),
@@ -78,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Segment",
[],
None,
"{id, subpath, segment, t, dx, dy} bend a segment so the point at t moves by (dx, dy)",
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
has_doc,
reshape_segment
),
@@ -141,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blob Brush",
[],
None,
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
has_doc,
blob
),
@@ -772,7 +774,16 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let mut removed = 0;
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, anchors) in jobs {
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
let shape: Vec<(usize, bool)> =
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
let next = following_anchors(&shape, &anchors);
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
removed += 1;
} else if !next.is_empty() {
// The next anchor along is selected, so the next Remove Anchor Points goes on from
// there without picking it first (#2241).
sel.anchors.insert(id, next);
}
}
emptied.finish(d)?;
Ok(removed)
@@ -780,6 +791,30 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "removedObjects": removed }))
}
/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a
/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its
/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's
/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors.
fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
let mut next = BTreeSet::new();
let mut kept = 0;
for (si, &(n, closed)) in shape.iter().enumerate() {
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
if n - gone.len() < 2 {
continue;
}
if let Some(&last) = gone.last() {
let wrap = if closed { 0..last } else { 0..0 };
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
next.insert((kept, a - gone.range(..a).count()));
}
}
kept += 1;
}
next
}
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
/// through `emptied`. True when the path went.
@@ -854,10 +889,12 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
if !(dv.x.is_finite() && dv.y.is_finite()) {
return Err(bad(C, "invalid delta"));
}
let perpendicular = bool_or(p, "perpendicular", false);
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if !sp.bend_segment(seg, t, dv) {
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
if !bent {
return Err(EngineError::Other("no such segment".into()));
}
Ok(())
@@ -1312,11 +1349,14 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
} else {
Paint::solid(Color::BLACK)
};
let keep_selected = bool_or(p, "keepSelected", false);
let only_selection = bool_or(p, "mergeOnlySelection", false);
let st = s.doc()?;
let mut merge_ids = vec![];
if bool_or(p, "merge", true) {
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
let all = Selection::default();
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
let from = if only_selection { &st.selection } else { &all };
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
let Some(n) = st.doc.node(id) else { continue };
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
continue;
@@ -1327,20 +1367,26 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let ap = Appearance::basic(paint, Paint::None, 1.0);
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
if merge_ids.is_empty() {
return add_node(s, "Blob Brush", path_kind(region), ap, None);
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
}
let mut inputs: Vec<(&PathData, FillRule)> = vec![(&region, FillRule::NonZero)];
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
let merged = po::unite_all(&inputs);
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
};
let keep = *keep;
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
s.edit("Blob Brush", |d, sel| {
*path_mut(d, keep)? = merged;
remove_each(d, &others)?;
sel.set([keep]);
if keep_selected {
sel.set([keep]);
} else {
sel.remove(keep);
}
Ok(())
})?;
Ok(json!({ "id": keep.0 }))
+161 -35
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
@@ -37,13 +37,21 @@ 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!(
query "document.rulerOrigin",
"Ruler Origin",
[],
None,
"{artboard?: index=0 (the active artboard, which artboard rulers measure from)} → {x, y} the rulers' zero point, in absolute document points",
has_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",
"{x, y, artboard?: index=0} set the rulers' zero point in absolute document points: the document's with global rulers, that artboard's with artboard rulers (it moves with the artboard); art, guides and command coordinates stay in place",
has_doc,
set_ruler_origin
),
@@ -52,10 +60,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Ruler Origin",
[],
None,
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner (with artboard rulers, that artboard's own zero point)",
has_doc,
reset_ruler_origin
),
cmd!(
"view.globalRulers",
"Change to Global Rulers",
["View", "Rulers"],
Some("Cmd+Alt+R"),
"{global?: bool (default: toggle)} global rulers measure the whole document from one zero point; artboard rulers (a new document's) from the active artboard's top-left corner, or the zero point set for it. Saved with the document → {global}",
has_doc,
global_rulers
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
@@ -83,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Edit"],
Some("Cmd+V"),
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Offset)
),
@@ -172,6 +189,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
}
/// Most node ids `document.inspect {ids}` takes.
const MAX_INSPECT_IDS: usize = 10_000;
fn doc_inspect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.inspect";
let opts = slice_opts(p, C)?;
let ids = match p.get("ids") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))),
Some(_) => Some(checked_ids_param(s, p, "ids", C)?),
};
let kind = match p.get("kind") {
None | Some(Value::Null) => None,
Some(Value::String(k)) => Some(k.clone()),
Some(_) => return Err(bad(C, "`kind` must be a string")),
};
let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) };
Ok(inspect::document_scoped(s, opts, &scope))
}
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
@@ -209,33 +246,91 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
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"))?,
};
if index >= s.doc()?.doc.artboards.len() {
return Err(bad(c, "index out of range"));
}
Ok(index)
}
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
// A document without artboards measures from its global zero point.
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
}
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")
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
change_ruler_origin(s, index, Some(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")
let index = artboard_param(s, p, "document.resetRulerOrigin")?;
change_ruler_origin(s, index, None, "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 {
/// Move the rulers' zero point to `origin`, or back to artboard `index`'s top-left corner: the
/// document's (global rulers) or that artboard's (artboard rulers, kept as an offset from its
/// corner so it follows the artboard).
fn change_ruler_origin(s: &mut Session, index: usize, origin: Option<Point>, label: &str) -> Result<Value> {
let d = &s.doc()?.doc;
let global = d.rulers == RulerMode::Global;
let corner = d.artboards.get(index).map(|ab| Point::new(ab.rect.x0, ab.rect.y0));
let target = origin.or(corner).unwrap_or(Point::ZERO);
if !target.is_finite() || target.x.abs() > crate::MAX_COORD || target.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 {
if global {
if d.ruler_origin != target {
s.edit(label, |d, _| {
d.ruler_origin = target;
Ok(())
})?;
}
return Ok(json!(target));
}
let Some((id, corner)) = d.artboards.get(index).map(|ab| ab.id).zip(corner) else {
return Err(bad("document.setRulerOrigin", "index out of range"));
};
let offset = Some(target - corner).filter(|o| *o != Vec2::ZERO);
if d.artboard_ruler_origins.get(&id).copied() != offset {
s.edit(label, |d, _| {
d.ruler_origin = origin;
// Origins of artboards that no longer exist go too.
let ids: BTreeSet<u32> = d.artboards.iter().map(|ab| ab.id).collect();
d.artboard_ruler_origins.retain(|id, _| ids.contains(id));
match offset {
Some(o) => d.artboard_ruler_origins.insert(id, o),
None => d.artboard_ruler_origins.remove(&id),
};
Ok(())
})?;
}
Ok(json!(origin))
Ok(json!(target))
}
/// View › Rulers › Change to Global / Artboard Rulers.
fn global_rulers(s: &mut Session, p: &Value) -> Result<Value> {
let current = s.doc()?.doc.rulers == RulerMode::Global;
let global = match p.get("global") {
None => !current,
Some(v) => v.as_bool().ok_or_else(|| bad("view.globalRulers", "global must be true or false"))?,
};
if global != current {
let label = if global { "Change to Global Rulers" } else { "Change to Artboard Rulers" };
s.edit(label, |d, _| {
d.rulers = if global { RulerMode::Global } else { RulerMode::Artboard };
Ok(())
})?;
}
Ok(json!({ "global": global }))
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).
@@ -276,6 +371,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
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();
@@ -288,12 +384,17 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
// Guides locked since the step was made stay deselected.
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
super::maskedit::follow_history(st, editing);
Ok(json!({ "undone": label }))
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
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();
@@ -306,6 +407,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
@@ -324,9 +428,23 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
}
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
/// rest still go to the compound path, which holds the paint.
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true))
}
/// 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> {
roots_within(doc, ids, false)
}
/// [`roots_of`]; with `members`, a compound path's member listed stays itself.
fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> 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).
@@ -336,7 +454,7 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
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())),
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())),
_ => (id, path.cloned()),
}
})
@@ -482,16 +600,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let st = s.doc()?;
let same_doc = clip.source_doc == Some(st.uid);
let parent = st.target_parent()?;
// A copied artboard comes back with its art: right of the last artboard, or where it was
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
let board = clip
.artboard
.as_ref()
// Copied artboards come back with their art: together right of the last artboard, as they lay,
// or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the
// art.
let boards = clip
.artboards
.iter()
.map(|a| a.rect)
.reduce(|a, b| a.union(b))
.filter(|_| mode != PasteMode::AllArtboards)
.map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } else { Vec2::ZERO }));
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
// always goes back into its layers.
let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
// Front/back: relative to the selection (top-most / bottom-most selected object); with
// nothing selected, the top / bottom of the current layer.
let anchor = match mode {
@@ -502,7 +623,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
}
_ => None,
};
let placements: Vec<Affine> = match (mode, board) {
let placements: Vec<Affine> = match (mode, boards) {
(_, Some((_, dv))) => vec![Affine::translate(dv)],
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
@@ -515,9 +636,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
// In place, in front, in back: onto the active artboard when the UI names it.
_ => vec![onto_artboard(&st.doc, p, clip)],
};
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| {
let imported = clip.import_into(d, choices, same_doc);
let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv));
let artboards: Vec<usize> = boards
.into_iter()
.flat_map(|(all, dv)| all.iter().map(move |a| (a, dv)))
.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv))
.collect();
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
// Objects pasted into each parent so far (keeps their order in front and back pastes).
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
@@ -552,15 +677,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
// right away (opening the file later would, but paste must not depend on a reopen).
super::typecmd::refresh_bounds_of(d, &new_ids);
Ok((new_ids, imported, artboard))
Ok((new_ids, imported, artboards))
})?;
// The Artboard tool takes the pasted artboard.
if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") {
s.set_tool_option("active", &json!(i));
// The Artboard tool selects the pasted artboards, the first one active.
if !artboards.is_empty() && s.tool_id() == "artboard" {
s.set_tool_option("selected", &json!(artboards));
}
Ok(json!({
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
"artboard": artboard,
"artboard": artboards.first(),
"artboards": artboards,
"added": imported.added,
"merged": imported.merged,
"renamed": imported.renamed,
+40 -5
View File
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}",
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
can_expand,
expand_appearance
),
@@ -138,6 +138,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
super::params::check_effect_color(C, id, &params)?;
// Crop Marks take their style from the preferences when applied.
if id == effects::CROP_MARKS && params.get("style").is_none() {
params["style"] = json!(s.crop_mark_style().id());
@@ -313,7 +314,11 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let visible = p.get("visible").and_then(Value::as_bool);
let item = item_target(s, p, C)?;
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
let edited = set_params_effects(s, p);
for effect in &edited {
super::params::check_effect_color(C, effect, &params)?;
}
if edited.iter().any(|e| e == GLASS) {
load_texture(&mut params, C)?;
}
for id in appearance_targets(s, p)? {
@@ -504,10 +509,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
expand_pieces(d, &mut m, false, &mut stroke_art);
m
})
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
effects::expand_leaf(d, &v, &mut stroke_art)
} else {
// Type, images, symbol instances, live objects: through their outlines.
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
// them with a gradient): through their outlines.
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
@@ -545,6 +551,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let failed = vectorcraft_render::decode_failures();
let ids = s.edit("Expand Appearance", |d, _| {
let brushes = vectorcraft_brush::library(d);
let mut out = vec![];
@@ -556,7 +563,12 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
let mut out = json!({ "ids": ids_json(&ids) });
// Raster effects become images rendered from the art.
if let Some(w) = rasterfx::undecoded_warning(failed) {
out["warning"] = json!(w);
}
Ok(out)
}
#[cfg(test)]
@@ -995,4 +1007,27 @@ mod tests {
assert_eq!(ch.len(), 1);
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
}
#[test]
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
let (mut s, id) = session_with_rect();
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
s.edit("gradient", |d, _| {
let n = d.node_mut(id).unwrap();
n.appearance.set_fill(ramp);
Ok(())
})
.unwrap();
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
let mut boxes = vec![];
node(&s, id).walk(&mut |n| {
if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) {
boxes.push(n.geometric_bounds().unwrap().width());
}
});
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
}
}
+429 -14
View File
@@ -1,8 +1,9 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient and pattern
//! fills (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
//! group shaped like the object. Create Gradient Mesh shares the sampling.
use std::sync::Arc;
@@ -12,13 +13,14 @@ use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
use vectorcraft_doc::live::{GradientMesh, lerp_color};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use vectorcraft_doc::pattern::{PatternDef, place_art};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
use super::edit::selected_roots;
use super::menucmds::{run_raw, squash};
use super::object::as_clipping_path;
use super::pathops::{assemble, node_path, replace_leaves, styled_copy, with_transparency};
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
use super::*;
const C: &str = "object.expand";
@@ -37,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -46,11 +48,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Options",
[],
None,
"{} what each Expand option would change in the selection → {object, fill, stroke} (true: something to expand)",
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to)",
has_selection,
|s, _| {
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke}))
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient}))
}
),
]
@@ -99,6 +101,8 @@ struct Expandable {
live: bool,
effects: bool,
fill: bool,
/// Gradient fills (the ones Expand Gradient To applies to), a part of `fill`.
gradient: bool,
stroke: bool,
/// An envelope (Object expands it into its distorted content).
envelope: bool,
@@ -108,6 +112,8 @@ struct Expandable {
placed: bool,
/// Repeats (Object expands them into groups of their instances).
repeats: bool,
/// Intertwine groups (Object expands them into groups of what they draw).
intertwines: bool,
}
impl Expandable {
@@ -123,18 +129,26 @@ impl Expandable {
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
e.gradient |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
};
e.fill |= e.gradient;
e.fill |= match &c.kind {
NodeKind::Text(t) => {
t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
}
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
};
});
}
e
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
}
}
@@ -149,6 +163,17 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
}
}
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
match i {
AppearanceItem::Fill(f) if f.visible => match &f.paint {
Paint::Pattern { pattern, xf } => Some((f, d.pattern(pattern)?, *xf)),
_ => None,
},
_ => None,
}
}
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let object = bool_or(p, "object", true);
let fill = bool_or(p, "fill", true);
@@ -162,6 +187,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
}
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
// over the others, expand below.
if object && e.intertwines {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
}
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
@@ -203,6 +234,7 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
}
if fill {
let _ = expand_gradient_fills(s, to);
let _ = expand_pattern_fills(s);
}
if s.doc()?.revision == rev0 {
return Err(EngineError::Other("Expand: nothing to expand".into()));
@@ -241,18 +273,27 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> {
}
/// Path `l` with each gradient fill turned into art, in appearance order: the other items stay
/// on plain copies of the path. `None` when it has no gradient fill.
/// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own
/// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no
/// gradient fill can be expanded.
fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
return None;
}
let reshaped = reshaped(l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
let mut parts = vec![];
let mut rest = vec![];
let mut expanded = false;
for item in &l.appearance.items {
// Items that paint nothing go; a gradient that can't be expanded stays.
let Some(art) = gradient_fill(item).and_then(|(f, g)| gradient_art(d, l, &path, bounds, f, g, to)) else {
let art = gradient_fill(item).and_then(|(f, g)| {
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
gradient_art(d, &fl, &path, bounds, f, g, to)
});
let Some(art) = art else {
if item.visible() && !item.paint().is_none() {
rest.push(item.clone());
}
@@ -262,6 +303,10 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
}
parts.push(art);
expanded = true;
}
if !expanded {
return None;
}
if !rest.is_empty() {
parts.push(styled_copy(d, l, rest));
@@ -270,7 +315,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
}
/// Gradient fill `f` (gradient `g`) of path `l` (outline `path`, box `bounds`) as art, with the
/// fill's opacity, blend mode and effects.
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
let content = if g.gradient.kind == GradientKind::Freeform {
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
@@ -299,7 +344,377 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
};
let mut art = with_transparency(d, art, f.opacity, f.blend);
art.appearance.effects = f.effects.clone();
art.appearance.effects = fill_effects(f);
Some(art)
}
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
/// visible geometry effect.
fn reshaped(l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry};
if !has_geometry(&l.appearance.effects) {
return None;
}
let (path, _) = node_path(l)?;
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
let mut m = with_path(l, g);
m.appearance.effects.retain(|e| !is_geometry(&e.id));
Some(m)
}
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
/// as a plain path, and that outline's box.
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
if !has_geometry(&f.effects) {
return Some((l.clone(), path.clone(), bounds));
}
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
let b = outline.bounds()?;
Some((with_path(l, outline.clone()), outline, b))
}
/// Path or compound path `l` with `path` as its path, a plain path.
fn with_path(l: &Node, path: PathData) -> Node {
let mut m = l.clone();
m.kind = match &l.kind {
NodeKind::Path { rule, clipping, guide, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: *clipping, guide: *guide },
NodeKind::Compound { rule, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false },
k => k.clone(),
};
m
}
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
/// shape the outline it paints ([`fill_shape`]).
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
}
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
fn expand_pattern_fills(s: &mut Session) -> Result<()> {
let roots = selected_roots(s)?;
s.edit("Expand", |d, sel| {
let (mut ids, mut changed) = (vec![], 0);
for r in &roots {
let (id, n) = replace_leaves(d, *r, expand_patterns)?;
ids.push(id);
changed += n;
}
if changed == 0 {
return Err(EngineError::Other("Expand: nothing to expand".into()));
}
sel.set(ids);
Ok(())
})
}
/// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it
/// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects
/// other than geometry effects, in appearance order: the other items stay on plain copies of the
/// path. The path's geometry effects ([`reshaped`]) and each fill's own ([`fill_shape`]) shape
/// that outline first, as on the canvas. The tiles are drawn as the canvas draws them
/// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill
/// lays a tile.
fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) {
return None;
}
let reshaped = reshaped(l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
let mut parts = vec![];
let mut rest = vec![];
let mut expanded = false;
for item in &l.appearance.items {
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
let tiles = fill.and_then(|(f, def, xf)| {
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
let t = pattern_tiles(d, &def, xf, bounds);
(!t.is_empty()).then_some((f, fl, t))
});
let Some(art) = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)) else {
if item.visible() && !item.paint().is_none() {
rest.push(item.clone());
}
continue;
};
if !rest.is_empty() {
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
}
parts.push(art);
expanded = true;
}
if !expanded {
return None;
}
if !rest.is_empty() {
parts.push(styled_copy(d, l, rest));
}
assemble(d, l, parts)
}
/// The tiles pattern `def` placed by `xf` lays over `bounds` in document `d`, as the canvas draws
/// them: their stroke weights and effects scale with the pattern, and when it also turns, flips,
/// stretches or shears them, the strokes (brushed ones included) and geometry effects of their
/// paths and opacity masks become filled art and their live shapes become plain paths first. The
/// same happens when it scales art with geometry effects (Roughen's detail, for one, is per inch
/// and does not scale). No tiles when it turns, flips, stretches or shears art with raster or 3D
/// effects or with a geometry effect on a group or type, or scales art with a geometry effect on a
/// group or type, which the canvas draws turned or scaled with the tile and live effects would
/// not, and when it does more than move art that holds a symbol instance, whose strokes the canvas
/// draws scaled with the tile and an instance does not scale. Random Transform effects and brushes
/// that vary at random in the art are baked first ([`seeds_baked`], [`random_brushes_baked`]).
fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect) -> Vec<Node> {
if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) {
return vec![];
}
let Some(def) = seeds_baked(def) else {
return vec![];
};
let brushes = vectorcraft_brush::library(d);
let def = random_brushes_baked(d, &brushes, def);
let def = &*def;
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), patterns: true, ..Scaling::default() };
if scales_evenly(xf) && (moves_only(xf) || !def.art.iter().any(|a| has_geometry_effects(a))) {
return def.instances_in_with(xf, bounds, scaling);
}
if def.art.iter().any(|a| has_unbakeable_effects(a)) {
return vec![];
}
let placed = Scaling { patterns: true, ..Scaling::default() };
let mut tiles = def.instances_in_with(Affine::IDENTITY, xf.inverse().transform_rect_bbox(bounds), placed);
for t in &mut tiles {
*t = prepared(d, &brushes, t);
place_art(t, xf, scaling);
}
tiles
}
/// Pattern `def` with each path in its art (opacity masks included) that has a random Transform
/// effect baked into filled art ([`bake_appearance`](vectorcraft_render::effects::bake_appearance))
/// while it keeps its own id: the effect takes its seed from the id, and the canvas draws the art
/// with those ids for every tile, while the tiles get ids of their own. `None` when something
/// other than a path has such an effect.
fn seeds_baked(def: &PatternDef) -> Option<std::borrow::Cow<'_, PatternDef>> {
if !def.art.iter().any(|a| has_seeded(a)) {
return Some(std::borrow::Cow::Borrowed(def));
}
let mut def = def.clone();
def.art = def.art.iter().map(|a| bake_seeded(a).map(Arc::new)).collect::<Option<_>>()?;
Some(std::borrow::Cow::Owned(def))
}
/// Pattern `def` with each path in its art (opacity masks included) whose stroke has a brush that
/// varies at random ([`draws_random_art`]) [`prepared`] where the pattern defines it: the brush
/// takes its seed from the path's points, and the canvas draws the art there for every tile.
fn random_brushes_baked<'a>(
d: &mut Document,
brushes: &[vectorcraft_brush::Brush],
def: std::borrow::Cow<'a, PatternDef>,
) -> std::borrow::Cow<'a, PatternDef> {
if !def.art.iter().any(|a| has_random_brush(brushes, a)) {
return def;
}
let mut def = def.into_owned();
def.art = def.art.iter().map(|a| Arc::new(bake_random_brushes(d, brushes, a))).collect();
std::borrow::Cow::Owned(def)
}
/// Does brush `name` of `brushes` draw different art for different points: a calligraphic or
/// scatter brush with Random variation, or a bristle brush?
fn draws_random_art(brushes: &[vectorcraft_brush::Brush], name: &str) -> bool {
use vectorcraft_brush::{BrushKind, Variation};
brushes.iter().find(|b| b.name == name).is_some_and(|b| match &b.kind {
BrushKind::Calligraphic(c) => c.modes().contains(&Variation::Random),
BrushKind::Scatter(s) => s.modes().contains(&Variation::Random),
BrushKind::Bristle(_) => true,
BrushKind::Art(_) | BrushKind::Pattern(_) => false,
})
}
/// Is `n` a path with a visible stroke whose brush varies at random ([`draws_random_art`])?
fn random_brushed(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
let random = |st: &vectorcraft_doc::appearance::StrokeLayer| st.visible && st.brush.as_deref().is_some_and(|b| draws_random_art(brushes, b));
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
&& n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if random(st)))
}
/// Does `n`, something in it or one of their opacity masks have a stroke whose brush varies at
/// random ([`random_brushed`])?
fn has_random_brush(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
random_brushed(brushes, n)
|| n.extras().mask.as_ref().is_some_and(|m| has_random_brush(brushes, &m.art))
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_random_brush(brushes, c))))
}
/// `n` with the paths in it whose brush varies at random [`prepared`] ([`random_brushes_baked`]).
fn bake_random_brushes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
if !has_random_brush(brushes, n) {
return n.clone();
}
if random_brushed(brushes, n) {
return prepared(d, brushes, n);
}
let mut n = n.clone();
if !matches!(n.kind, NodeKind::Compound { .. }) {
for c in n.children_mut().into_iter().flatten() {
*c = Arc::new(bake_random_brushes(d, brushes, c));
}
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
m.art = Arc::new(bake_random_brushes(d, brushes, &m.art));
}
n
}
/// Does `n`, something in it or one of their opacity masks have a visible geometry effect?
fn has_geometry_effects(n: &Node) -> bool {
let geometry = |e: &vectorcraft_doc::Effect| e.visible && vectorcraft_render::effects::is_geometry(&e.id);
n.appearance.effects.iter().any(geometry)
|| n.appearance.items.iter().any(|i| i.effects().iter().any(geometry))
|| n.extras().mask.as_ref().is_some_and(|m| has_geometry_effects(&m.art))
|| n.children().is_some_and(|c| c.iter().any(|c| has_geometry_effects(c)))
}
/// Does `n`, something in it or one of their opacity masks hold a symbol instance?
fn has_instances(n: &Node) -> bool {
matches!(n.kind, NodeKind::SymbolInstance { .. })
|| n.extras().mask.as_ref().is_some_and(|m| has_instances(&m.art))
|| n.children().is_some_and(|c| c.iter().any(|c| has_instances(c)))
}
/// Does `a` only move (no scale, turn, flip, stretch or shear)?
fn moves_only(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let eps = 1e-9;
(m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps
}
/// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's?
fn seeded_here(n: &Node) -> bool {
let seeded =
|e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true);
n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded))
}
/// Does `n`, something in it or one of their opacity masks have a random Transform effect
/// ([`seeded_here`])?
fn has_seeded(n: &Node) -> bool {
seeded_here(n)
|| n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art))
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c))))
}
/// `n` with the paths in it that have a random Transform effect baked ([`seeds_baked`]). `None`
/// when something else has one.
fn bake_seeded(n: &Node) -> Option<Node> {
if !has_seeded(n) {
return Some(n.clone());
}
let mut n = if seeded_here(n) {
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
return None;
}
vectorcraft_render::effects::bake_appearance(n)?
} else {
n.clone()
};
if !matches!(n.kind, NodeKind::Compound { .. }) {
for c in n.children_mut().into_iter().flatten() {
*c = Arc::new(bake_seeded(c)?);
}
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
m.art = Arc::new(bake_seeded(&m.art)?);
}
Some(n)
}
/// Does `n`, something in it or one of their opacity masks have a visible effect that [`prepared`]
/// cannot bake: a raster or 3D effect, or a geometry effect on something other than a path?
fn has_unbakeable_effects(n: &Node) -> bool {
use vectorcraft_render::effects::{is_geometry, is_raster, is_three_d};
let path = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
let unbakeable = |e: &vectorcraft_doc::Effect| e.visible && (is_raster(&e.id) || is_three_d(&e.id) || (!path && is_geometry(&e.id)));
n.appearance.effects.iter().any(unbakeable)
|| n.appearance.items.iter().any(|i| i.effects().iter().any(unbakeable))
|| n.extras().mask.as_ref().is_some_and(|m| has_unbakeable_effects(&m.art))
|| n.children().is_some_and(|c| c.iter().any(|c| has_unbakeable_effects(c)))
}
/// Tile art `n` ready for a turn, flip, stretch or shear: the strokes and geometry effects of its
/// paths baked ([`baked`], [`outline_brushed`]) and its live shapes turned into plain paths (a
/// stretch would rebuild their corners round), in its opacity masks too.
fn prepared(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
let mut t = baked(n);
outline_brushed(d, brushes, &mut t);
drop_live(&mut t);
prepare_masks(d, brushes, &mut t);
t
}
/// The opacity masks of `n` and of everything in it [`prepared`].
fn prepare_masks(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
m.art = Arc::new(prepared(d, brushes, &m.art));
}
for c in n.children_mut().into_iter().flatten() {
prepare_masks(d, brushes, Arc::make_mut(c));
}
}
/// `n` with each path in it that has a brushed stroke turned into filled art and brush art
/// ([`outline_strokes`]).
fn outline_brushed(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
let brushed = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()));
if brushed && let Some(o) = outline_strokes(d, brushes, n) {
*n = o;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
outline_brushed(d, brushes, Arc::make_mut(c));
}
}
/// Is `a` a move and a positive uniform scale (which brush nibs, strokes and effects follow by
/// scaling alone)?
fn scales_evenly(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let eps = 1e-9 * m0.abs().max(m3.abs());
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
}
/// `n` with the strokes and geometry effects of each path in it baked into filled art
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)).
fn baked(n: &Node) -> Node {
if let Some(b) = vectorcraft_render::effects::bake_appearance(n) {
return b;
}
let mut n = n.clone();
for c in n.children_mut().into_iter().flatten() {
*c = Arc::new(baked(c));
}
n
}
/// Pattern tiles `tiles` of fill `f` of path `l` as a clip group shaped like `l` that isolates
/// their blending (as the canvas draws a tile on its own), with the fill's opacity, blend mode and
/// effects other than geometry effects ([`fill_effects`]).
fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option<Node> {
let mut clip = styled_copy(d, l, vec![]);
as_clipping_path(&mut clip).ok()?;
let tiles: Vec<Node> = tiles.iter().map(|t| d.reid(t)).collect();
let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect();
let mut art = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true });
art.isolate = true;
let mut art = with_transparency(d, art, f.opacity, f.blend);
art.appearance.effects = fill_effects(f);
Some(art)
}
+111 -8
View File
@@ -5,7 +5,9 @@
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
use serde_json::{Value, json};
use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use super::super::*;
@@ -125,6 +127,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
#[cfg(not(target_arch = "wasm32"))]
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
use super::distinct_names;
use super::screens::file_part;
let st = s.doc()?;
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
if names.is_empty() {
@@ -134,12 +139,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
let steps = filter_steps(run.steps, true, false);
let run = Run { steps: &steps, ..*run };
let mut processed = Vec::new();
let mut failed = 0usize;
for name in &names {
match process_one_dataset(s, &run, name, &doc_stem) {
for (name, stem) in names.iter().zip(&stems) {
match process_one_dataset(s, &run, name, stem) {
Ok(row) => processed.push(row),
Err(e) => {
failed += 1;
@@ -182,14 +191,12 @@ fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext:
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
s.execute("dataset.select", &json!({"name": name}))?;
// File + Data Set Name naming when overriding Save/Export (helpx).
let stem = format!("{doc_stem}_{name}");
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft");
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
// Data sets share one open document: write each row out but leave it open for the next.
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
finish_destination(s, run, stem, "vectorcraft", "", false)?;
Ok(json!({"dataset": name, "result": batch}))
}
@@ -432,6 +439,102 @@ mod tests {
assert_eq!(written.len(), 2, "{written:?}");
}
/// The names of the files in `dir`, sorted.
fn file_names(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> =
std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
names.sort();
names
}
/// A new document with an empty data set for each of `names`.
fn with_data_sets(names: &[&str]) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
for name in names {
s.execute("dataset.new", &json!({"name": name})).unwrap();
}
s
}
#[test]
fn batch_keeps_data_set_files_inside_the_destination_folder() {
let root = tmp("set-names-root");
let dest = root.join("out");
// The folder that `a/../../x` would pass through on its way out of `out`.
std::fs::create_dir_all(dest.join("Untitled_a")).unwrap();
let mut s = with_data_sets(&["a/../../x", "../y"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_..-y.vectorcraft", "Untitled_a", "Untitled_a-..-..-x.vectorcraft"], "{r}");
assert_eq!(file_names(&root), ["out"], "nothing is written outside the destination folder");
}
#[test]
fn batch_saves_data_sets_named_with_separators() {
let dest = tmp("set-names-sep");
let exports = tmp("set-names-sep-export");
let mut s = with_data_sets(&["a/b", "c\\d", "e:f"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_a-b.vectorcraft", "Untitled_c-d.vectorcraft", "Untitled_e-f.vectorcraft"], "{r}");
// Override Export points each data set's export at the destination folder under the same name.
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destinationFolder": exports.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
}
#[test]
fn batch_gives_each_data_set_a_file_of_its_own() {
let dest = tmp("set-names-same");
let mut s = with_data_sets(&["a:b", "a-b", "A-B"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
}
#[test]
fn batch_refuses_nesting_and_empty_commands() {
let mut s = Session::new();
+4 -1
View File
@@ -99,7 +99,7 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
/// written.
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
let open = std::mem::take(&mut s.docs);
s.docs = start;
@@ -107,5 +107,8 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
// Past the newest revision the document reached, so the restored one redraws.
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
st.revision = newest.saturating_add(1);
if s.menu.guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
}
}
+51 -4
View File
@@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
}
}
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
/// contradict it). The image size limits are checked with the scale, as for an artboard.
fn page_rect(f: &Format, p: &Value) -> Result<Option<Rect>> {
let v = match p.get("rect") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
if !f.raster {
return Err(bad(C, format!("rect applies to raster formats, not {}", f.label)));
}
if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards")));
}
let nums: Option<Vec<f64>> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect());
let Some(&[x, y, w, h]) = nums.as_deref() else {
return Err(bad(C, "rect is [x, y, width, height] in points"));
};
let (x1, y1) = (x + w, y + h);
if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) {
return Err(bad(C, "rect needs finite numbers and a positive width and height"));
}
Ok(Some(Rect::new(x, y, x1, y1)))
}
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
with_single_artboard(doc.clone(), rect, name)
@@ -359,7 +385,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
/// features approximated or left out; every format that draws type: fonts written in the fallback
/// font).
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
let f = super::writable(C, Some(format), None)?;
encode_all(doc, f.id, p)?.single(f)
@@ -387,10 +413,14 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
let failed = vectorcraft_render::decode_failures();
let mut enc = encode_files(doc, f, p)?;
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
vectorcraft_doc::cancel::check()?;
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
// that silently lacks it.
if f.raster && vectorcraft_render::decode_failures() > failed {
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
if f.raster
&& let Some(w) = crate::cmd::rasterfx::undecoded_warning(failed)
{
enc.warnings.push(w.into());
}
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
// themselves: SVG only when it outlines or embeds fonts).
@@ -408,6 +438,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("rect");
o.remove("useArtboards");
}
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
}
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);
@@ -467,7 +506,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
};
let mut enc = Encoded::default();
let mut renderer = vectorcraft_render::Renderer::new();
for b in chosen {
let total = chosen.len() as u64;
for (done, b) in chosen.into_iter().enumerate() {
vectorcraft_doc::cancel::check()?;
vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1));
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
check_format_size(f, region.width() * scale, region.height() * scale)?;
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
@@ -479,11 +521,16 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
} else {
bytes
};
// PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false.
let bytes =
if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes };
enc.files.push((Some(b), bytes));
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
if format == RasterFormat::WebP && o.lossless == Some(false) {
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
}
+2
View File
@@ -270,6 +270,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
if pages.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect();
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);
@@ -302,6 +303,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
}
}
}
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
}
+16 -8
View File
@@ -94,11 +94,12 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
}
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
/// out, at any depth of layers, sublayers and groups that don't clip, and objects that reach it
/// stay whole (the file's bounds clip them), as DXF's crop leaves them. Symbol instances are
/// measured by their art, and the box of one whose art holds brushed strokes grows by as far as
/// their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol
/// instance also stays when what the canvas paints for it reaches `rect`
/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer
/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it.
/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them.
/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
/// its own art, not a whole document of artboards.
@@ -122,8 +123,9 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
let mut holds_instance = false;
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
}
let symbols = doc.symbol_boxes().with_brush_art();
let mut d = doc.clone();
@@ -165,13 +167,19 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Co
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
/// visual bounds.
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = selected_artwork(s)?;
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
}
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
/// layers are guides and are left out.
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
}
/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what
+46 -17
View File
@@ -76,9 +76,10 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
return format("svgz");
}
if vectorcraft_pdf::is_postscript(bytes) {
// PostScript (.eps, and .ai saved without PDF compatibility) by any name; .ai and .ait
// keep their meaning (a template), read as PostScript all the same.
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("eps"));
// PostScript (.eps, .ps, and .ai saved without PDF compatibility) by any name; .ai, .ait
// and .ps keep their meaning (a template, a PostScript file), read as PostScript all the
// same.
return by_name.filter(|f| matches!(f.id, "ai" | "ait" | "ps")).or_else(|| format("eps"));
}
if bytes.starts_with(b"%PDF") {
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
@@ -167,6 +168,12 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
if !read {
return (doc, warnings);
}
// The layers' text objects hold type and the stroked paths around it, which can include stroked
// glyphs the PDF part kept as outlines: without any such glyph in the document, the warnings on
// outlines are dropped.
if !vectorcraft_pdf::holds_outlines(&doc) {
warnings.retain(|w| !vectorcraft_pdf::is_outlines_note(w));
}
// 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);
@@ -197,8 +204,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let mut converted = false;
let mut preview_only = false;
let (mut doc, mut warnings, restored) = match format.id {
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
restore_or_import(editing, || {
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
@@ -377,6 +384,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
doc.template = false;
doc.title = s.next_untitled();
}
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
// closed, its top-level layers as its groups (#1475). Ours carry their own.
if matches!(format.id, "ai" | "ait") && !restored && doc.layers_open.is_none() {
doc.layers_open = Some(vec![]);
}
// Save writes the file back only in a format it writes, only a whole PDF (see
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
// writes .ai that way).
@@ -436,24 +448,37 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
/// nothing for them, so Open, Place and the Links panel say it (#1479). `None` for images that
/// decode, and for bytes that aren't an image.
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode,
/// counted as RGBA (4 bytes a pixel) or as the decoder gives them when that is more (its header
/// says so; the pixels aren't read): Open, Place and the Links panel say it (#1479). `None` for
/// images within the allowance, and for bytes that aren't an image.
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
use image::ImageDecoder as _;
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
return Some(format!("an image of {w} × {h} pixels is too large to draw"));
}
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
let (w, h) = d.dimensions();
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
// The allowance also counts the pixels as RGBA: an image of fewer than 4 bytes a pixel (gray,
// gray with alpha, RGB) takes more than the decoder counts (#1717).
let rgba_over = u64::from(w) * u64::from(h) > MAX_RASTER_ALLOC / 4;
(rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
}
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or
/// other extra channels, which the decoder can't read either, become RGBA too.
/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
/// either, become RGBA too, whether they store their channels together or one plane per channel.
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
}
if psdread::is_psd(bytes) {
let cms = vectorcraft_color::cms::active();
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
@@ -464,10 +489,14 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
reader.limits(limits);
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some();
if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
let kept = match f.id {
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
"tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi),
_ => None,
};
if let Some(kept) = kept {
let (width, height) = reader.into_dimensions().map_err(err)?;
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
return stored(kept, f.mime, (width, height), ppi);
}
let extra_samples = || {
let cms = vectorcraft_color::cms::active();
+2
View File
@@ -42,6 +42,8 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kin
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
}
+83 -19
View File
@@ -34,6 +34,7 @@ mod legacy_ai;
mod load;
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
mod metafile;
mod naming;
mod native;
pub mod pdf;
mod pdfimport;
@@ -63,6 +64,7 @@ pub use load::{
read_for_open, texture_image,
};
pub use load::{losses_summary, overwrite_losses};
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
@@ -71,7 +73,10 @@ pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub use screens::{
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
};
@@ -118,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -136,10 +141,37 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
cmd!(
query "document.exportPresets.list",
"Export Presets",
[],
None,
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
always,
screens::presets_list
),
cmd!(
query "document.exportPresets.save",
"Save Export Preset",
[],
None,
"{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, antiAlias, folder, fullDocument} (as document.exportForScreens; other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused → {name, created, settings}",
always,
screens::presets_save
),
cmd!(
query "document.exportPresets.delete",
"Delete Export Preset",
[],
None,
"{name} delete a saved batch export preset (built-in ones stay) → {deleted}",
always,
screens::presets_delete
),
cmd!(
query "command.batch",
"Batch",
@@ -154,7 +186,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
always,
action_batch::action_batch
),
@@ -279,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
default: "null",
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 RECT: FormatOption = FormatOption {
name: "rect",
ty: "array",
default: "null",
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
};
const PPI: FormatOption = FormatOption {
name: "ppi",
ty: "number",
@@ -316,7 +354,7 @@ const EMBED_ICC: FormatOption = FormatOption {
name: "embedIcc",
ty: "boolean",
default: "true",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
};
const IMAGE_MAP: FormatOption = FormatOption {
name: "imageMap",
@@ -366,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -387,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
};
const TIFF_OPTIONS: &[FormatOption] =
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
const BMP_COLOR_MODEL: FormatOption =
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
const BMP_DEPTH: FormatOption = FormatOption {
@@ -415,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -434,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
default: "24",
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
};
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const PSD_LAYERS: FormatOption = FormatOption {
name: "layers",
ty: "boolean",
@@ -458,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -528,7 +569,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
},
Format {
id: "jpg",
@@ -543,6 +584,7 @@ pub const FORMATS: &[Format] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -564,7 +606,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
},
Format {
id: "tiff",
@@ -593,7 +635,7 @@ pub const FORMATS: &[Format] = &[
Format {
id: "eps",
label: "EPS",
extensions: &["eps"],
extensions: &["eps", "epsf"],
mime: "application/postscript",
read: true,
write: true,
@@ -609,6 +651,8 @@ pub const FORMATS: &[Format] = &[
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
// Photoshop's large document format: read like a PSD (its merged image), never written.
reader("psb", "PSB", &["psb"], "image/x-psb", true),
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
reader("ps", "PostScript", &["ps"], "application/postscript", false),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -635,6 +679,8 @@ pub const OPEN_EXTS: &[&str] = &[
"emf",
"wmf",
"eps",
"epsf",
"ps",
"af",
"afdesign",
"afpub",
@@ -642,8 +688,9 @@ pub const OPEN_EXTS: &[&str] = &[
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
/// them (File › Open and Place still read them).
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"];
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
/// them all).
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
@@ -660,7 +707,10 @@ pub fn export_extensions() -> Vec<&'static str> {
/// Text files: File → Place sets them as area type (Text Import Options).
pub const TEXT_EXTS: &[&str] = &["txt"];
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
/// RTF files: File → Place sets them as area type with their formatting.
pub const RTF_EXTS: &[&str] = &["rtf"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
@@ -684,10 +734,13 @@ pub const PLACE_EXTS: &[&str] = &[
"emf",
"wmf",
"eps",
"epsf",
"ps",
"af",
"afdesign",
"afpub",
"txt",
"rtf",
];
/// One dialog filter per readable format.
@@ -711,11 +764,13 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
/// and RTF.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
.chain(std::iter::once(("RTF", RTF_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
@@ -819,6 +874,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Work that stops early for a cancel can leave a truncated result: none of it is written.
vectorcraft_doc::cancel::check()?;
may_write(path)?;
// Never a half-written file: see [`write_atomic`].
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
@@ -827,6 +884,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
/// [`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<()> {
vectorcraft_doc::cancel::check()?;
may_write(path)?;
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -1031,14 +1089,18 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) ->
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
/// (compared without case).
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
distinct_names(names.into_iter().map(|(name, fallback)| {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() { fallback } else { base }
}))
}
/// `names` with `-2`, `-3`… after a name already used (compared without case).
fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
let mut taken = std::collections::HashSet::new();
names
.into_iter()
.map(|(name, fallback)| {
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() {
base = fallback;
}
.map(|base| {
let mut name = base.clone();
for i in 2.. {
if taken.insert(name.to_lowercase()) {
@@ -1143,6 +1205,8 @@ mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
#[cfg(test)]
mod tests_batch;
#[cfg(test)]
mod tests_screens;
#[cfg(test)]
+159
View File
@@ -0,0 +1,159 @@
//! File-name templates for batch exports (`document.exportForScreens {name}`): the same pattern
//! gives every exported file a consistent name, such as `COMPANY-Logo-<filename>-####-YYYYMMDD`.
//!
//! Tokens:
//! - `<filename>` (or `<document>`): the document's name without its extension;
//! - `<name>` (or `<artboard>`, `<item>`): the artboard's, asset's, layer's or selection's name;
//! - `<index>`: the item's place in the export, from 1;
//! - a run of `#`: the counter, zero-padded to the run's length (`####` → `0001`);
//! - `<format>` (or `<ext>`): the file's extension; `<scale>`: the row's size (`2x`, `100w`);
//! `<suffix>`: the row's suffix (otherwise added at the end);
//! - the date and time: `YYYYMMDD`, `YYYY-MM-DD` and `YYYY` as written, and `<date>`
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
//!
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
/// What a template's tokens stand for, for one file.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NameParts<'a> {
pub filename: &'a str,
pub name: &'a str,
pub index: usize,
pub counter: u64,
pub format: &'a str,
pub scale: &'a str,
pub suffix: &'a str,
/// `[year, month, day, hour, minute, second]`.
pub when: [i64; 6],
}
/// The bracketed tokens and what each stands for.
fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
let [y, mo, d, h, mi, s] = p.when;
Some(match name {
"filename" | "document" => p.filename.to_string(),
"name" | "artboard" | "item" => p.name.to_string(),
"index" => p.index.to_string(),
"format" | "ext" => p.format.to_string(),
"scale" => p.scale.to_string(),
"suffix" => p.suffix.to_string(),
"date" => format!("{y:04}{mo:02}{d:02}"),
"time" => format!("{h:02}{mi:02}{s:02}"),
"YYYY" => format!("{y:04}"),
"MM" => format!("{mo:02}"),
"DD" => format!("{d:02}"),
"hh" => format!("{h:02}"),
"mm" => format!("{mi:02}"),
"ss" => format!("{s:02}"),
_ => return None,
})
}
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
fn clean(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
pub fn has_suffix_token(template: &str) -> bool {
template.contains("<suffix>")
}
/// The file name (without extension) `template` gives for `p`.
pub fn render(template: &str, p: &NameParts<'_>) -> String {
let mut t = template.trim();
for ext in [".ext", ".<ext>", ".<format>"] {
if let Some(stripped) = t.strip_suffix(ext) {
t = stripped;
}
}
let [y, mo, d, ..] = p.when;
let mut out = String::with_capacity(t.len() + 16);
let mut rest = t;
while let Some(c) = rest.chars().next() {
// Bracketed tokens; an unknown one stays as written.
if c == '<'
&& let Some(end) = rest.find('>')
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
{
out.push_str(&clean(&value));
rest = rest.get(end + 1..).unwrap_or("");
continue;
}
if c == '#' {
let run = rest.chars().take_while(|c| *c == '#').count();
out.push_str(&format!("{:0run$}", p.counter));
rest = rest.get(run..).unwrap_or("");
continue;
}
// The date spelled out, longest first.
let date = [("YYYYMMDD", format!("{y:04}{mo:02}{d:02}")), ("YYYY-MM-DD", format!("{y:04}-{mo:02}-{d:02}")), ("YYYY", format!("{y:04}"))];
if let Some((pat, value)) = date.iter().find(|(pat, _)| rest.starts_with(pat)) {
out.push_str(value);
rest = rest.get(pat.len()..).unwrap_or("");
continue;
}
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
rest = rest.get(c.len_utf8()..).unwrap_or("");
}
out.trim().to_string()
}
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
pub fn parse_date(s: &str) -> Option<[i64; 6]> {
let digits: String = s.trim().chars().filter(|c| *c != '-').collect();
if digits.len() != 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let num = |a: usize, b: usize| digits.get(a..b).and_then(|t| t.parse::<i64>().ok());
let (y, m, d) = (num(0, 4)?, num(4, 6)?, num(6, 8)?);
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some([y, m, d, 0, 0, 0])
}
#[cfg(test)]
mod tests {
use super::*;
fn parts() -> NameParts<'static> {
NameParts { filename: "Brand", name: "Dark", index: 3, counter: 7, format: "png", scale: "2x", suffix: "@2x", when: [2026, 10, 16, 9, 5, 0] }
}
#[test]
fn the_example_from_the_request() {
assert_eq!(render("COMPANY-Logo-<filename>-####-YYYYMMDD.ext", &parts()), "COMPANY-Logo-Brand-0007-20261016");
}
#[test]
fn every_token() {
let p = parts();
assert_eq!(render("<document>_<name>_<artboard>_<item>", &p), "Brand_Dark_Dark_Dark");
assert_eq!(render("<index>-#-##-<format>-<ext>-<scale><suffix>", &p), "3-7-07-png-png-2x@2x");
assert_eq!(render("<date>T<time> <YYYY>/<MM>/<DD> <hh>:<mm>:<ss>", &p), "20261016T090500 2026-10-16 09-05-00");
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
// Words are left alone: only the date patterns and bracketed tokens are replaced.
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
}
#[test]
fn no_name_climbs_out_of_its_folder() {
let p = NameParts { name: "../../etc", ..parts() };
let n = render("a/b\\c:<name>", &p);
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
// A long counter is padded, a short run is not cut.
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
}
#[test]
fn dates_parse() {
assert_eq!(parse_date("2026-10-16"), Some([2026, 10, 16, 0, 0, 0]));
assert_eq!(parse_date("20261016"), Some([2026, 10, 16, 0, 0, 0]));
for bad in ["", "2026-13-01", "2026-1-1", "today", "2026-10-1x"] {
assert_eq!(parse_date(bad), None, "{bad}");
}
}
}
+17
View File
@@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) {
pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
match e {
PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()),
PdfError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
}
}
@@ -253,9 +254,21 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
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);
warnings.extend(overflow_warning(cmd, &full, p)?);
Ok((bytes, warnings))
}
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
/// whose text doesn't fit (see [`crate::cmd::fonts::export_overflow_warning`]): on the pages
/// chosen, leaving out the layers that don't print unless the PDF writes them.
fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<String>> {
let opts = options(cmd, doc, p)?;
let areas = vectorcraft_pdf::page_areas(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
let set = &opts.settings;
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
Ok(crate::cmd::fonts::export_overflow_warning(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas)))
}
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
/// native document with its save options, whose pages may be left blank).
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
@@ -273,7 +286,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
}
}
let set = &opts.settings;
if set.pdf13() {
vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency");
}
let flat = flatten_for(cmd, doc, set, p)?;
vectorcraft_doc::cancel::check()?;
if flat.is_some() {
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
+29 -5
View File
@@ -185,6 +185,9 @@ fn parent_folder(path: &str) -> Option<String> {
fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> {
match format(f) {
Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f),
Some(f) if TEMPLATE_FORMATS.contains(&f.id) => {
Err(format!("Save writes {}, not {}: use file.saveAsTemplate to write it", SAVE_FORMATS.join(", "), f.label))
}
Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)),
None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))),
}
@@ -196,6 +199,18 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft"))
}
/// `path` for a template in format `f`: kept when its extension names `f`, with the extension
/// replaced when it names another format (`card.png` → `card.vctemplate`), else with `f`'s
/// extension added (`card`, `card v1.2`).
fn template_path(path: &str, f: &Format) -> String {
let ext = f.extensions[0];
match format_for_name(path) {
Some(g) if g.id == f.id => path.to_string(),
Some(_) => std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(),
None => format!("{path}.{ext}"),
}
}
/// [`save_format_of`] for command `cmd`.
fn checked(cmd: &str, f: &str) -> Result<&'static Format> {
save_format_of(f).map_err(|e| bad(cmd, e))
@@ -217,6 +232,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
(_, None, Some(f)) => checked(cmd, f.id)?,
(_, None, None) => checked(cmd, st.format)?,
};
let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path };
let mut given: Map<String, Value> = match p.get("options") {
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
None | Some(Value::Null) => Map::new(),
@@ -280,17 +296,18 @@ fn is_svg(f: &Format) -> bool {
}
/// The document as written: native files carry the view to reopen at and the Layers panel's open
/// rows.
/// rows and transparency grid visibility.
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
if !is_native(f) {
return st.doc.clone();
}
let open = st.layers_open.saved(&st.doc);
if st.doc.last_view == st.view && st.doc.layers_open == open {
if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid {
return st.doc.clone();
}
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
d.transparency_grid = st.transparency_grid;
d.layers_open = open;
Arc::new(d)
}
@@ -631,7 +648,8 @@ impl SaveJob {
/// 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).
/// made keep the document modified; changes that are not edits to save don't,
/// [`DocState::same_content`]).
pub fn finish(self, st: &mut DocState) {
let retargets = self.plan.retargets();
if let (true, Some(path)) = (retargets, self.plan.path) {
@@ -643,6 +661,12 @@ impl SaveJob {
if let Some(at) = self.stamped {
if Arc::ptr_eq(&st.doc, &self.source) {
st.doc = self.snapshot.clone();
} else if st.same_content(&self.source) {
// Changed while the save ran only by changes that are not edits to save
// (Puppet Warp pins dropped, Edit Text ended, type laid out again after a font
// scan): with the dates, the document counts as saved.
stamp_save_dates(st, at);
return st.mark_saved();
} else {
// Edited while the save ran: the dates still go on, the edits stay unsaved. The
// undo history holds the document as it was before the dates, so that is the
@@ -755,7 +779,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save As…",
["File"],
Some("Cmd+Shift+S"),
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
has_doc,
|s, p| save(s, SaveMode::SaveAs, p)
),
@@ -773,7 +797,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save as Template…",
["File"],
None,
"{…as document.save} {path?, format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
has_doc,
|s, p| save(s, SaveMode::Template, p)
),
+390 -86
View File
@@ -1,20 +1,25 @@
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
//! whole document, or the chosen assets of the Asset Export panel) in every format row at each
//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP);
//! the presets the dialog offers; and the settings the document remembers
//! whole document, the chosen assets of the Asset Export panel, layers, or the selection) in every
//! format row at each row's size, into a folder and its sub-folders, or returned for download (each
//! file, or one ZIP); the presets the dialog offers; and the settings the document remembers
//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]).
//!
//! A batch export names every file from one pattern (`name`, see [`super::naming`]: the document's
//! and the item's names, a counter, the date), and sorts the files into folders by row, by format,
//! by item, or by item and format (`folders`).
use std::collections::HashSet;
use std::sync::Arc;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::{isolated, without_artboards};
use super::export::{isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
};
@@ -143,9 +148,9 @@ fn safe_name(s: &str) -> Option<String> {
(!n.trim_matches('.').is_empty()).then_some(n)
}
/// Text that goes into a file name (prefix, suffix): path separators and `:` become `-`, so it can't
/// start a folder or climb out of the export folder.
fn file_part(s: &str) -> String {
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
pub(super) fn file_part(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
@@ -161,6 +166,8 @@ struct Row {
folder: String,
/// The encoder options: the format's settings, then the row's own.
options: Value,
/// What makes two rows the same file: format, size, suffix and options.
key: String,
}
impl Row {
@@ -169,8 +176,8 @@ impl Row {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz or pdf"));
if matches!(format.id, "template" | "txt" | "dxf" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
// left over from a raster row switched to SVG or PDF).
@@ -203,13 +210,21 @@ impl Row {
let options = Value::Object(options);
// A PDF preset (built-in or saved) becomes its settings.
let options = if format.id == "pdf" { super::pdf::expand_preset(s, C, &options)?.into_owned() } else { options };
Ok(Self { format, size, suffix, folder, options })
// Two rows that would write the same bytes under the same name are one file.
let key = format!("{}|{}|{suffix}|{options}", format.id, size.label());
Ok(Self { format, size, suffix, folder, options, key })
}
/// Formats that hold a whole document to edit (native, Illustrator, EPS): written from a
/// document of the item alone, one artboard, rather than with an `artboard` option.
fn whole_document(&self) -> bool {
matches!(self.format.id, "vectorcraft" | "ai" | "eps")
}
/// The encoder options for `region`, `artboard` of the document written.
fn options_for(&self, region: Rect, artboard: Option<usize>) -> Result<Value> {
let mut o = self.options.clone();
if let Some(b) = artboard {
if let Some(b) = artboard.filter(|_| !self.whole_document()) {
o["artboard"] = json!(b);
}
if self.format.raster {
@@ -247,6 +262,60 @@ struct ScreenParams {
prefix: Option<String>,
/// Asset ids (Asset Export) to write instead of artboards.
assets: Option<Vec<u64>>,
/// Layer ids to write instead of artboards: each layer's art alone.
layers: Option<Vec<u64>>,
/// The selection instead of artboards: `one` file of it all, or one for `each` object.
selection: Option<String>,
/// The file name pattern ([`naming`]); without it, `prefix` + name + suffix.
name: Option<String>,
/// The counter's first value (`####` in `name`).
counter_start: Option<u64>,
/// The counter counts `item`s (every file of an artboard has its number) or `file`s.
counter_per: Option<String>,
/// The date the pattern's date tokens use (`YYYY-MM-DD`); default today (UTC).
date: Option<String>,
/// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as
/// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`.
folders: Option<String>,
}
/// How exported files are sorted into folders.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Folders {
None,
Rows,
Format,
Item,
ItemFormat,
}
impl Folders {
fn of(o: &ScreenParams) -> Result<Self> {
Ok(match o.folders.as_deref() {
None if o.subfolders || o.preset.is_some() => Self::Rows,
None | Some("none" | "") => Self::None,
Some("rows") => Self::Rows,
Some("format") => Self::Format,
Some("item") => Self::Item,
Some("itemFormat") => Self::ItemFormat,
Some(f) => return Err(bad(C, format!("folders `{f}`: none, rows, format, item or itemFormat"))),
})
}
}
/// One thing a batch export writes a file of per row: an artboard, an asset, a layer, the
/// selection or one selected object, or the whole document.
struct Item {
/// Its name as the user gave it (`<name>`).
name: String,
/// Its name made safe and unique among the items (the default file name, the item folder).
stem: String,
/// The document written: the whole document with `artboard`, or the item alone.
doc: Arc<Document>,
artboard: Option<usize>,
region: Rect,
/// Full Document: what a PDF row writes (every artboard of the document).
every_artboard: Option<Arc<Document>>,
}
impl ScreenParams {
@@ -289,8 +358,9 @@ pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
/// The file names (and bytes) an export wrote, or the files it returns.
#[derive(Default)]
struct Output {
/// Lower-case relative names already used (two identical rows write one file).
taken: HashSet<String>,
/// Lower-case relative names already used, with what wrote each (two identical rows write
/// one file; different files that would share a name get `-2`, `-3`…).
taken: std::collections::HashMap<String, String>,
/// Sub-folders already made.
made: HashSet<String>,
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
@@ -300,9 +370,30 @@ struct Output {
}
impl Output {
/// Is `name` new (not written by an identical row already)?
fn claim(&mut self, name: &str) -> bool {
self.taken.insert(name.to_lowercase())
/// The name the file `key` writes as `name` takes: `name`, else (with `number`) `name-2`,
/// `name-3`… when another file has it; `None` when the same file (`key`) was written as `name`
/// already, or, without `number`, when any file was (the first row wins, as Export for Screens
/// always did when no name pattern is given).
fn claim(&mut self, name: &str, key: &str, number: bool) -> Option<String> {
let (base, ext) = name.rsplit_once('.').map_or((name, ""), |(b, e)| (b, e));
for n in 1.. {
let candidate = if n == 1 {
name.to_string()
} else if ext.is_empty() {
format!("{base}-{n}")
} else {
format!("{base}-{n}.{ext}")
};
match self.taken.get(&candidate.to_lowercase()) {
Some(k) if k == key || !number => return None,
Some(_) => continue,
None => {
self.taken.insert(candidate.to_lowercase(), key.to_string());
return Some(candidate);
}
}
}
None
}
/// Write `bytes` as `name` (relative) under `folder`, or keep them.
@@ -324,32 +415,166 @@ impl Output {
}
}
/// File → Export for Screens: [`export`], with the params remembered in the document
/// (`document.exportSettings`).
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
/// the params remembered in the document (`document.exportSettings`).
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
let r = export(s, p)?;
remember(s, p);
let p = with_preset(s, p)?;
let r = export(s, &p)?;
remember(s, &p);
Ok(r)
}
/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or
/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or
/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get
/// `-2`, `-3`… instead of overwriting.
// ---------- export presets ----------
/// A saved batch export: what to write and how to name and sort it, to use again
/// (`document.exportPresets.*`, `exportPreset`). Kept with the preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScreenExportPreset {
pub name: String,
/// The [`PRESET_KEYS`] of `document.exportForScreens` params.
pub settings: Map<String, Value>,
}
/// What a preset keeps: the formats, their settings, the naming and the folders; not which art
/// (it changes with the document) nor where to (unless given).
pub const PRESET_KEYS: [&str; 13] = [
"formats",
"settings",
"preset",
"name",
"prefix",
"counterStart",
"counterPer",
"folders",
"subfolders",
"includeBleed",
"antiAlias",
"folder",
"fullDocument",
];
/// The built-in export presets: (name, settings).
pub fn builtin_presets() -> Vec<ScreenExportPreset> {
let marks = json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true});
let preset = |name: &str, settings: Value| ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() };
vec![
preset(
"Logo Package",
json!({
"name": "<filename>-<name>",
"folders": "format",
"formats": [
{"format": "svg"},
{"format": "png", "scale": "1x", "suffix": ""},
{"format": "png", "scale": "2x", "suffix": "@2x"},
{"format": "pdf"},
{"format": "pdf", "suffix": "-print", "marks": marks},
],
}),
),
preset("Print-Ready PDF", json!({"formats": [{"format": "pdf", "suffix": "-print", "marks": marks, "bleed": {"useDocument": true}}]})),
]
}
/// The preset called `name`: a saved one, else a built-in one.
fn find_preset(s: &Session, name: &str) -> Option<ScreenExportPreset> {
s.prefs.screen_export_presets.iter().find(|p| p.name == name).cloned().or_else(|| builtin_presets().into_iter().find(|p| p.name == name))
}
/// `p` over the settings of its `exportPreset`, when it names one (its own keys win).
fn with_preset(s: &Session, p: &Value) -> Result<Value> {
let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) };
let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?;
let mut merged = preset.settings;
if let Some(o) = p.as_object() {
merged.extend(o.iter().filter(|(k, _)| k.as_str() != "exportPreset").map(|(k, v)| (k.clone(), v.clone())));
}
Ok(Value::Object(merged))
}
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
let row = |p: &ScreenExportPreset, built_in: bool| json!({"name": p.name, "builtIn": built_in, "settings": p.settings});
let rows: Vec<Value> =
builtin_presets().iter().map(|p| row(p, true)).chain(s.prefs.screen_export_presets.iter().map(|p| row(p, false))).collect();
Ok(json!({ "presets": rows }))
}
pub(super) fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
const SAVE: &str = "document.exportPresets.save";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(SAVE, "give the preset a name"))?.to_string();
if builtin_presets().iter().any(|b| b.name == name) {
return Err(bad(SAVE, format!("`{name}` is a built-in preset: save under another name")));
}
let given = p.get("settings").and_then(Value::as_object).ok_or_else(|| bad(SAVE, "settings is an object of document.exportForScreens params"))?;
let settings: Map<String, Value> = given.iter().filter(|(k, _)| PRESET_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
// Refuse what an export would refuse, now rather than when the preset is used.
check_settings(s, &Value::Object(settings.clone())).map_err(|e| bad(SAVE, e.to_string()))?;
Folders::of(&ScreenParams::of(&Value::Object(settings.clone()))?).map_err(|e| bad(SAVE, e.to_string()))?;
let preset = ScreenExportPreset { name: name.clone(), settings };
let created = match s.prefs.screen_export_presets.iter_mut().find(|q| q.name == name) {
Some(slot) => {
*slot = preset.clone();
false
}
None => {
s.prefs.screen_export_presets.push(preset.clone());
true
}
};
Ok(json!({ "name": name, "created": created, "settings": preset.settings }))
}
pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
const DELETE: &str = "document.exportPresets.delete";
let name = str_param(p, "name").ok_or_else(|| bad(DELETE, "missing `name`"))?;
if builtin_presets().iter().any(|b| b.name == name) {
return Err(bad(DELETE, format!("`{name}` is built in and stays")));
}
let before = s.prefs.screen_export_presets.len();
s.prefs.screen_export_presets.retain(|q| q.name != name);
if s.prefs.screen_export_presets.len() == before {
return Err(bad(DELETE, format!("no saved export preset `{name}`")));
}
Ok(json!({ "deleted": name }))
}
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files the `name` pattern gives one name.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
if o.full_document && o.assets.is_some() {
return Err(bad(C, "give assets or fullDocument, not both"));
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
if sources > 1 {
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
}
let subfolders = o.subfolders || o.preset.is_some();
let folders = Folders::of(&o)?;
// A prefix names files, not folders.
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
let when = match o.date.as_deref() {
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
};
let per_file = match o.counter_per.as_deref() {
None | Some("item") => false,
Some("file") => true,
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
};
let counter_start = o.counter_start.unwrap_or(1);
let selection = match o.selection.as_deref() {
None => None,
Some("one") => Some(false),
Some("each") => Some(true),
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
};
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
if let Some(dir) = folder {
create_dir(dir)?;
}
let doc = s.doc()?.doc.clone();
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// Include Bleed: each artboard grown by the document's bleed.
let doc = if o.include_bleed && doc.setup.has_bleed() {
let mut d = (*doc).clone();
@@ -360,79 +585,66 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
} else {
doc
};
let name_of = |r: &Row, stem: &str| {
let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]);
if subfolders { format!("{}/{file}", r.folder) } else { file }
};
let crop = rows.iter().any(|r| r.format.id != "pdf");
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
let mut out = Output::default();
// Appearance of Black › Printing / Exporting for RGB raster rows.
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
if o.full_document {
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
let mut whole: Option<Document> = None;
// A whole-document copy of an artboard, for the formats that write documents to edit.
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
let mut files = 0u64;
for (index, item) in items.iter().enumerate() {
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue;
}
let bytes = if r.format.id == "pdf" {
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
Some(pattern) => {
let parts = NameParts {
filename: &filename,
name: &item.name,
index: index + 1,
counter,
format: ext,
scale: &if r.format.raster { r.size.label() } else { String::new() },
suffix: &r.suffix,
when,
};
let named = naming::render(pattern, &parts);
let named = if named.is_empty() { item.stem.clone() } else { named };
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
}
None => format!("{prefix}{}{}", item.stem, r.suffix),
};
let file = format!("{stem}.{ext}");
let sub = match folders {
Folders::None => None,
Folders::Rows => Some(r.folder.clone()),
Folders::Format => Some(ext.to_string()),
Folders::Item => Some(item.stem.clone()),
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
};
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
// One page per artboard.
let mut options = r.options.clone();
options["range"] = json!("all");
write(&doc, "pdf", &options)?
write(all, "pdf", &options)?
} else if r.whole_document() && item.artboard.is_some() {
let b = item.artboard.unwrap_or(0);
let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone();
write(&d, r.format.id, &r.options_for(item.region, None)?)?
} else {
if whole.is_none() {
let bounds =
vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
whole = Some(single_artboard(&doc, bounds, "Document"));
}
let Some(d) = whole.as_ref() else { continue };
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
write(d, r.format.id, &r.options_for(region, Some(0))?)?
write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)?
};
out.put(folder, o.zip, name, bytes)?;
}
} else if let Some(ids) = &o.assets {
let assets =
ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::<Result<Vec<_>>>()?;
if assets.is_empty() {
return Err(bad(C, "no assets to export"));
}
for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) {
let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name)
.ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue;
}
let bytes = write(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
} else {
let n = doc.artboards.len();
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect);
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue; // the same file from two identical rows
}
let bytes = write(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
out.put(folder, o.zip, name, bytes)?;
}
files += 1;
}
}
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()));
let zip_name = format!("{prefix}{filename}.zip");
return match folder {
Some(dir) => {
let path = format!("{dir}/{zip_name}");
@@ -450,6 +662,98 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
/// selection (one item, or one per object), else the chosen artboards.
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
// The art of `ids` alone, as an item named `name`.
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
};
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
pieces
.into_iter()
.zip(stems)
.map(|((name, doc, artboard, region), stem)| Item { name, stem, doc, artboard, region, every_artboard: None })
.collect()
};
if o.full_document {
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
// row writes every artboard of the document itself).
let (whole, region) = if crop {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
(Arc::new(single_artboard(doc, bounds, "Document")), bounds)
} else {
(doc.clone(), Rect::ZERO)
};
let name = filename.to_string();
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
}
if let Some(ids) = &o.assets {
let mut pieces = vec![];
for id in ids {
let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?;
let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
pieces.push((a.name.clone(), d, Some(0), r));
}
if pieces.is_empty() {
return Err(bad(C, "no assets to export"));
}
return Ok(named(pieces, "Asset"));
}
if let Some(ids) = &o.layers {
let mut pieces = vec![];
for id in ids {
let layer = doc.node(NodeId(*id)).filter(|n| n.is_layer()).ok_or_else(|| bad(C, format!("no layer {id} (see layer.list)")))?;
let mut art = vec![];
crate::cmd::assets::art_under(doc, layer.id, &mut art);
let name = layer.display_name();
let (d, r) = alone(&art, &name).ok_or_else(|| bad(C, format!("layer `{name}` has no art to export")))?;
pieces.push((name, d, Some(0), r));
}
if pieces.is_empty() {
return Err(bad(C, "no layers to export"));
}
return Ok(named(pieces, "Layer"));
}
if let Some(each) = selection {
if selected.is_empty() {
return Err(bad(C, "select something to export"));
}
let mut pieces = vec![];
if each {
for id in selected {
let name = doc.node(*id).map(|n| n.display_name()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Selection".into());
if let Some((d, r)) = alone(&[*id], &name) {
pieces.push((name, d, Some(0), r));
}
}
} else if let Some((d, r)) = alone(selected, "Selection") {
pieces.push(("Selection".to_string(), d, Some(0), r));
}
if pieces.is_empty() {
return Err(bad(C, "the selection has no art to export"));
}
return Ok(named(pieces, "Selection"));
}
let n = doc.artboards.len();
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = artboard_file_names(doc, &boards);
Ok(boards
.into_iter()
.zip(stems)
.map(|(b, stem)| {
let a = doc.artboards.get(b);
let name = a.map(|a| a.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| stem.clone());
Item { name, stem, doc: doc.clone(), artboard: Some(b), region: a.map_or(Rect::ZERO, |a| a.rect), every_artboard: None }
})
.collect())
}
/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and
/// `assets`, a choice of art), so the dialog reopens on them.
fn remember(s: &mut Session, p: &Value) {
+5
View File
@@ -157,6 +157,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
// 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;
// A chosen artboard's file holds the art over it; otherwise the file holds all of it.
let regions: Option<Vec<vectorcraft_geom::Rect>> =
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
// 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);
@@ -184,5 +188,6 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
{
enc.warnings.push(w);
}
enc.warnings.extend(overflow);
Ok(enc)
}
+99 -2
View File
@@ -114,6 +114,100 @@ fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() {
assert_eq!(rgb.blob.mime, "image/png");
}
/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a
/// sample, chunky or `planar`.
fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec<u16>]) -> Vec<u8> {
let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels };
vectorcraft_testkit::tiff::tiff(&t)
}
/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image
/// reader decodes from the stored image, as RGBA.
fn kept(what: &str, bytes: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) {
let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}");
assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution");
let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw();
(r.blob.cmyk().map(|i| i.data), rgba)
}
/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do:
/// grayscale and RGB with extra channels, and CMYK with and without them (#1466).
#[test]
fn planar_tiffs_read_as_their_chunky_versions() {
let ch = |v: [u16; 6]| v.to_vec();
let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4]));
let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90]));
let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0]));
// Pixel by pixel, the samples of `px`.
let interleave = |px: &[&Vec<u16>]| -> Vec<u8> { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() };
let opaque = ch([255; 6]);
let inks = interleave(&[&c, &m, &y, &k]);
// (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK)
type Case = (u16, Vec<u16>, Vec<Vec<u16>>, Option<Vec<u8>>, Option<Vec<u8>>);
let cases: Vec<Case> = vec![
(1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
(1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
(2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None),
(2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None),
(5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())),
(5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())),
(5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None),
];
for (photometric, extra, channels, want_rgba, want_inks) in cases {
for bits in [8, 16] {
let wide: Vec<Vec<u16>> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits");
let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide));
let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide));
assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps");
assert_eq!(planar.0, want_inks, "{what}: the inks");
// A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with
// its 8-bit inks: their RGB pixels may be a level apart.
let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1);
assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1);
if let Some(want) = &want_rgba {
assert_eq!(&planar.1, want, "{what}: the pixels");
}
}
}
}
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
let plane = |v: u16| vec![v; 6];
let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]);
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
// eighth, at offset 102).
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
file[102..106].copy_from_slice(&1u32.to_le_bytes());
let e = raster_image(&file).err().expect("refused");
assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}");
assert!(too_large_note(&file).is_some());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
}
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
/// reads (#1466).
#[test]
fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]];
let file = hand_tiff(5, 8, &[], true, &inks);
let want: Vec<u8> = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect();
assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now");
let mut s = Session::new();
open(&mut s, "planar.tif", &file);
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks");
let mut s = session(100.0, 100.0, 1);
s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks");
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
}
#[test]
fn content_beats_a_wrong_extension() {
let png = image_bytes(2, 2, image::ImageFormat::Png);
@@ -129,7 +223,8 @@ fn templates_open_untitled() {
let mut s = session(100.0, 100.0, 1);
let dir = tmp_dir("tpl");
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
// A template under a native file name still opens untitled.
std::fs::write(&path, b64(&s.execute("file.saveAsTemplate", &json!({})).unwrap())).unwrap();
let r = s.execute("document.open", &json!({"path": path})).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(s.doc().unwrap().path, None);
@@ -378,7 +473,9 @@ fn export_for_screens_one_page_per_pdf_unique_names() {
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
assert_eq!(r["files"][0]["name"], "[email protected]");
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
// A native row: each artboard as a document of its own to edit.
let r = s.execute("document.exportForScreens", &json!({"range": "1", "formats": [{"format": "vectorcraft"}]})).unwrap();
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".vectorcraft"), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
@@ -303,6 +303,144 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
}
/// `data` as ASCII85, up to the `~>`.
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
/// Page content drawing one glyph and then `rest` in 10 pt on the baseline at `y`, from x = 14, in
/// rendering mode `mode` (0 fills the type, 1 strokes it). The glyph is code 0x41 of the font `F2`,
/// standing for the "fi" of the story, and `F2` isn't installed and its `ToUnicode` map gives that
/// glyph U+FFFD, so that the PDF part keeps it as outlines; `rest` is in Helvetica (`F1`).
fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String {
format!("{mode} Tr BT /F2 10 Tf 14 {y} Td (A) Tj ET BT /F1 10 Tf 19 {y} Td ({rest}) Tj ET ")
}
/// A `.ai` whose page draws a gray square and `type_content` (see [`half_outlined_line`]), and whose
/// layer `Art` has the square and text objects: the first of story 0, which `story` gives in the
/// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't
/// have. Without `story` the file has no text document.
fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec<u8> {
let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n");
let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect();
let mut body =
format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
if let Some(story) = story {
// The story's one line starts at the canvas center (8191.5, the template box's center) moved
// to (14, 65): its y runs down the page.
let n = story.chars().count();
let document = format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 10.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> >> >> >> /1 {} >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8105.5 8176.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 {} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n",
n + 1,
n + 1,
n + 1
);
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
body.push_str(&format!("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", lines.join("\n")));
}
let data = ai::editing_data(200.0, 100.0, &body);
let content = format!("0.5 g 120 20 40 40 re f 0.2 g 0.2 G {type_content}");
let page = PdfPage {
resources: format!(
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> \
/F2 << /Type /Font /Subtype /Type1 /BaseFont /VectorCraftTestUnnamed /ToUnicode {} 0 R >> >>",
first_extra(1) + 1
),
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
..PdfPage::new(200.0, 100.0, &content)
};
let cmap = "begincmap\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n1 beginbfchar\n<41> <FFFD>\nendbfchar\nendcmap\n";
pdf_with(
&[page],
&[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())],
None,
)
}
fn outlines_notes(r: &Value) -> Vec<String> {
let notes = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str);
notes.filter(|w| vectorcraft_pdf::is_outlines_note(w)).map(String::from).collect()
}
/// The notes the PDF part of [`half_outlined_ai`] reads with: its glyph of U+FFFD, kept as outlines.
fn pdf_part_outlines_notes() -> [String; 2] {
[format!("text in Vector Craft Test Unnamed {}", vectorcraft_pdf::UNNAMED_CHARACTERS_NOTE), vectorcraft_pdf::OUTLINED_TEXT_NOTE.into()]
}
/// The note on text objects that hold the page's type without some of its characters, for `n` of
/// them.
fn without_outlines_note(n: usize) -> Value {
json!(format!("{n} text objects hold their type as the page draws it, without the characters the page has only as outlines"))
}
/// The texts in the first layer.
fn texts(s: &Session) -> Vec<String> {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
}
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
/// text document, and stays with the PDF part alone.
#[test]
fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() {
let file = half_outlined_ai(Some("first draft"), 0, &half_outlined_line(65.0, "rst draft", 0));
let mut s = Session::new();
let r = open(&mut s, "draft.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(texts(&s), ["first draft"], "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
let r = open(&mut s, "draft.ai", &file, json!({"editingData": false})).unwrap();
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
// A second text object that takes the page's type, away from the glyph: no note on it.
let page = half_outlined_line(65.0, "rst draft", 0) + "BT /F1 10 Tf 14 30 Td (notes) Tj ET";
let r = open(&mut s, "notes.ai", &half_outlined_ai(Some("first draft"), 1, &page), json!({})).unwrap();
assert_eq!(texts(&s), ["first draft", "notes"], "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
}
/// A text object that takes the page's type lacks the glyphs next to it that the page kept as
/// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that
/// come in with the stroked type keep the PDF part's notes.
#[test]
fn type_from_the_page_opens_with_a_note_on_the_characters_it_has_only_as_outlines() {
let mut s = Session::new();
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 0)), json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(texts(&s), ["rst draft"], "{r}");
assert!(!vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
// Stroked: the glyph comes in with the type.
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 1)), json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert!(vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
assert!(!r["warnings"].to_string().contains("without the characters"), "{r}");
// A stroked line whose glyph comes in, and a filled one whose glyph doesn't.
let page = half_outlined_line(65.0, "rst draft", 1) + &half_outlined_line(30.0, "nd more", 0);
let r = open(&mut s, "both.ai", &half_outlined_ai(None, 1, &page), json!({})).unwrap();
assert_eq!(texts(&s), ["rst draft", "nd more"], "{r}");
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
}
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
@@ -374,3 +512,43 @@ fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
}
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
/// open.
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
let st = s.doc().unwrap();
let (mut rows, mut open) = (vec![], vec![]);
st.doc.walk(|n| {
if n.children().is_some() {
rows.push(n.name.clone().unwrap_or_default());
if st.layers_open.contains(n.id) {
open.push(n.name.clone().unwrap_or_default());
}
}
});
(rows, open)
}
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
/// open as before (with their top-level layers open unless the file says otherwise).
#[test]
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
let mut s = Session::new();
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
let (rows, open) = open_rows(&s);
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
assert_eq!(open, Vec::<String>::new(), "{r}");
}
// The same art as an EPS: its top-level layers open.
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
}
+205
View File
@@ -0,0 +1,205 @@
//! Batch exports through Export for Screens (#1935): a file name pattern (the document's and the
//! item's names, a counter, the date), folders by format, by item or both, layers and the selection
//! as what is exported, and document formats (VectorCraft, Illustrator, EPS) as rows.
use serde_json::{Value, json};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first.
fn session(artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap();
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
s
}
fn export(s: &mut Session, p: Value) -> Value {
s.execute("document.exportForScreens", &p).unwrap()
}
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect()
}
fn names(r: &Value) -> Vec<String> {
files(r).into_iter().map(|(n, _)| n).collect()
}
/// The request in #1935: SVG, PNG, PDF and PDF with printer's marks for artboards 1–3, named
/// `COMPANY-Logo-<filename>-####-YYYYMMDD`, sorted into a folder per extension.
#[test]
fn the_logo_package_of_the_request() {
let mut s = session(4);
let r = export(
&mut s,
json!({
"range": "1-3",
"name": "COMPANY-Logo-<filename>-####-YYYYMMDD.ext",
"date": "2026-10-16",
"folders": "format",
"formats": [
{"format": "svg"},
{"format": "png", "scale": "2x", "suffix": ""},
{"format": "pdf"},
{"format": "pdf", "suffix": "-print", "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}
]
}),
);
let n = names(&r);
assert_eq!(n.len(), 12, "{n:?}");
for (i, number) in ["0001", "0002", "0003"].iter().enumerate() {
let stem = format!("COMPANY-Logo-Untitled-1-{number}-20261016");
for want in [format!("svg/{stem}.svg"), format!("png/{stem}.png"), format!("pdf/{stem}.pdf"), format!("pdf/{stem}-print.pdf")] {
assert!(n.contains(&want), "artboard {}: {want} in {n:?}", i + 1);
}
}
// The printed PDF has room for its marks: a bigger page than the plain one.
let f = files(&r);
let page = |name: &str| {
let bytes = &f.iter().find(|(n, _)| n == name).unwrap().1;
let d = vectorcraft_pdf::import(bytes).unwrap();
let a = d.artboards.first().unwrap().rect;
(a.width(), a.height())
};
let plain = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016.pdf");
let print = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016-print.pdf");
assert!(print.0 > plain.0 && print.1 > plain.1, "{plain:?} vs {print:?}");
}
#[test]
fn folders_by_item_and_by_item_and_format() {
let mut s = session(2);
let rows = json!([{"format": "png"}, {"format": "svg"}]);
let by_item = names(&export(&mut s, json!({"folders": "item", "formats": rows})));
assert_eq!(by_item, ["Artboard-1/Artboard-1.png", "Artboard-1/Artboard-1.svg", "Artboard-2/Artboard-2.png", "Artboard-2/Artboard-2.svg"]);
let both = names(&export(&mut s, json!({"folders": "itemFormat", "formats": rows})));
assert_eq!(both[0], "Artboard-1/png/Artboard-1.png");
let flat = names(&export(&mut s, json!({"folders": "none", "subfolders": true, "formats": rows})));
assert_eq!(flat[0], "Artboard-1.png", "folders wins over subfolders");
// Without folders, subfolders keeps its meaning (a folder per row).
let rows_mode = names(&export(&mut s, json!({"subfolders": true, "formats": rows})));
assert_eq!(rows_mode[0], "1x/Artboard-1.png");
}
#[test]
fn the_counter_counts_items_or_files_from_any_start() {
let mut s = session(2);
let rows = json!([{"format": "png"}, {"format": "svg"}]);
let items = names(&export(&mut s, json!({"name": "logo-###", "counterStart": 9, "formats": rows})));
assert_eq!(items, ["logo-009.png", "logo-009.svg", "logo-010.png", "logo-010.svg"]);
let per_file = names(&export(&mut s, json!({"name": "logo-#", "counterPer": "file", "formats": rows})));
assert_eq!(per_file, ["logo-1.png", "logo-2.svg", "logo-3.png", "logo-4.svg"]);
// A pattern that names different files alike numbers them instead of dropping one.
let same = names(&export(&mut s, json!({"name": "logo", "formats": [{"format": "png"}]})));
assert_eq!(same, ["logo.png", "logo-2.png"]);
// The item's name, its place and the row's size and suffix.
let parts = names(&export(
&mut s,
json!({"name": "<index>_<name>_<scale><suffix>_<format>", "range": "2", "formats": [{"format": "png", "scale": "2x"}]}),
));
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
}
#[test]
fn layers_and_the_selection_are_exported_by_name() {
let mut s = session(1);
let first_layer = s.doc().unwrap().doc.layers[0].id.0;
let top = s.execute("layer.new", &json!({"name": "Dark Mode"})).unwrap()["id"].as_u64().unwrap();
let circle = s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
let r = export(&mut s, json!({"layers": [first_layer, top], "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["Layer-1.svg", "Dark-Mode.svg"]);
// The selection: one file of it all, or a file per object.
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
s.execute("select.set", &json!({"ids": [square, circle]})).unwrap();
assert_eq!(names(&export(&mut s, json!({"selection": "one", "formats": [{"format": "png"}]}))), ["Selection.png"]);
let each = names(&export(&mut s, json!({"selection": "each", "formats": [{"format": "png"}]})));
assert_eq!(each.len(), 2, "{each:?}");
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected");
}
/// Document formats write each artboard as a document of its own: one artboard, its art.
#[test]
fn document_formats_write_one_artboard_each() {
let mut s = session(2);
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]}));
let n = names(&r);
assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]);
let native = &files(&r)[0].1;
let mut t = Session::new();
t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap();
assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone");
}
#[test]
fn bad_batch_params_are_refused() {
let mut s = session(1);
for bad in [
json!({"folders": "byColour"}),
json!({"counterPer": "page"}),
json!({"date": "16/10/2026"}),
json!({"selection": "some"}),
json!({"selection": "one", "fullDocument": true}),
json!({"layers": [99999]}),
json!({"layers": [1], "assets": [1]}),
] {
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
}
}
/// Export presets: the built-in ones, saving (only what a preset keeps), using one under other
/// params, replacing, deleting, and keeping them with the preferences.
#[test]
fn export_presets_save_use_and_delete() {
let mut s = session(2);
let list = s.execute("document.exportPresets.list", &json!({})).unwrap();
let built_in: Vec<&str> =
list["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == true).map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(built_in, ["Logo Package", "Print-Ready PDF"]);
// The Logo Package: SVG, PNG 1x and 2x, PDF and PDF with marks, a folder per format.
let r = export(&mut s, json!({"exportPreset": "Logo Package", "range": "1"}));
assert_eq!(
names(&r),
[
"svg/Untitled-1-Artboard 1.svg",
"png/Untitled-1-Artboard 1.png",
"png/Untitled-1-Artboard [email protected]",
"pdf/Untitled-1-Artboard 1.pdf",
"pdf/Untitled-1-Artboard 1-print.pdf"
]
);
let settings = json!({"name": "ACME-<name>-##", "folders": "item", "formats": [{"format": "jpg", "quality": 80}], "range": "2", "assets": [1]});
let saved = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": settings})).unwrap();
assert_eq!(saved["created"], true);
assert!(saved["settings"].get("range").is_none() && saved["settings"].get("assets").is_none(), "which art is not kept: {saved}");
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2"}));
assert_eq!(names(&r), ["Artboard-2/ACME-Artboard 2-01.jpg"]);
// The call's own params win over the preset's.
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2", "folders": "none"}));
assert_eq!(names(&r), ["ACME-Artboard 2-01.jpg"]);
// Saving again replaces it; the preferences keep it.
let again = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": {"formats": [{"format": "png"}]}})).unwrap();
assert_eq!(again["created"], false);
let prefs = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(prefs["screenExportPresets"][0]["name"], "Web JPG");
s.execute("document.exportPresets.delete", &json!({"name": "Web JPG"})).unwrap();
assert!(s.execute("document.exportForScreens", &json!({"exportPreset": "Web JPG"})).is_err());
// Built-in names stay; bad settings are refused when saved.
for bad in [
("document.exportPresets.save", json!({"name": "Logo Package", "settings": {}})),
("document.exportPresets.save", json!({"name": "", "settings": {}})),
("document.exportPresets.save", json!({"name": "X", "settings": {"formats": [{"format": "txt"}]}})),
("document.exportPresets.save", json!({"name": "X", "settings": {"folders": "sideways"}})),
("document.exportPresets.save", json!({"name": "X"})),
("document.exportPresets.delete", json!({"name": "Logo Package"})),
("document.exportPresets.delete", json!({"name": "Never saved"})),
] {
assert!(s.execute(bad.0, &bad.1).is_err(), "{bad:?}");
}
}
+3 -1
View File
@@ -171,7 +171,9 @@ fn bad_eps_options_are_refused_and_formats_list_eps() {
let e = s.execute("document.exportEps", &p).unwrap_err().to_string();
assert!(e.contains("EPS"), "{p}: {e}");
}
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).is_err());
// Export for Screens writes an EPS per artboard (a document of that artboard alone).
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).unwrap();
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".eps"), "{r}");
let r = s.execute("document.formats", &json!({})).unwrap();
let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone();
// Readable too since EPS import (tests_epsimport.rs).
@@ -172,3 +172,44 @@ fn eps_files_place_as_one_group_clipped_to_their_bounding_box() {
assert!(format("eps").is_some_and(|f| f.read && f.write));
assert!(OPEN_EXTS.contains(&"eps") && PLACE_EXTS.contains(&"eps"));
}
/// PostScript `.ps` files open and place as EPS files do, under a PostScript format that exports
/// don't write, and `.epsf` files are EPS files; the open and place dialogs list both.
#[test]
fn ps_and_epsf_files_open_and_place() {
use crate::cmd::clipboard::{EPS, PASTE_ORDER, file_flavour};
// Pages without a bounding box (each artboard is a letter page), one of them a program whose
// header is only `%!`.
let page = b"%!PS-Adobe-3.0\n%%Pages: 1\n%%EndComments\n%%Page: 1 1\n1 0 0 setrgbcolor\n\
newpath 72 72 moveto 144 0 rlineto 0 72 rlineto -144 0 rlineto closepath fill\nshowpage\n%%EOF\n";
let bare = b"%!\n0 0 1 setrgbcolor\n100 100 50 50 rectfill\nshowpage\n";
let mut s = Session::new();
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("PAGE.PS", page, "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
let r = open(&mut s, name, bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((&r["format"], &r["warnings"]), (&json!(id), &json!([])), "{name}: {r}");
assert_eq!(objects(&s), 1, "{name}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(612.0, 792.0), "{name}");
}
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
let r = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!(id), Some(1)), "{name}: {r}");
}
// Copied in a file manager, they paste as EPS.
for (name, bytes) in [("page.ps", &page[..]), ("bare.ps", bare), ("bare.epsf", bare), ("bare.eps", bare)] {
assert_eq!(file_flavour(name, bytes, &PASTE_ORDER), Some(EPS), "{name}");
let r = s
.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(bytes)}))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["count"], json!(1), "{name}: {r}");
}
// The dialogs list them, and the desktop packaging doesn't associate them with the app.
assert!(format("ps").is_some_and(|f| f.read && !f.write) && format("epsf").is_some_and(|f| f.id == "eps" && f.write));
for e in ["ps", "epsf"] {
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
}
assert!(open_filters().any(|f| f == ("PostScript", &["ps"][..])) && place_filters().any(|f| f == ("PostScript", &["ps"][..])));
// Exports write no .ps: Print to PostScript does.
let e = writable_format(None, Some("out.ps")).unwrap_err();
assert_eq!(e, "PostScript files can be opened but not written (see document.formats)");
}
@@ -139,6 +139,27 @@ fn exports_write_ai_as_save_as_does() {
assert!(b64(&r).starts_with(b"%PDF"));
}
/// A `.ai` with an artboard larger than 14,400 pt has its PDF page written with a `UserUnit`,
/// and reopens at its size from its editing data and from its PDF part.
#[test]
fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20000, "height": 10000})).unwrap();
s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 5000, "height": 3000})).unwrap();
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
assert!(String::from_utf8_lossy(&ai).contains("/UserUnit 1.389"), "20,000 / 14,400 rounded up to the next thousandth");
let size = |s: &Session| {
let r = s.doc().unwrap().doc.artboards[0].rect;
(r.width().round(), r.height().round())
};
assert_eq!(open(&mut s, "big.ai", &ai)["restored"], true);
assert_eq!(size(&s), (20_000.0, 10_000.0));
// Picking its page opens the PDF part.
let r = s.execute("document.open", &json!({"name": "big.ai", "dataBase64": vectorcraft_format::base64_encode(&ai), "pages": "1"})).unwrap();
assert_eq!(r["restored"], false, "{r}");
assert_eq!(size(&s), (20_000.0, 10_000.0));
}
#[test]
fn ait_templates_open_their_document_untitled() {
let mut s = rich();
@@ -180,6 +201,37 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
}
/// Save as Template writes a file named with its template format's extension: another format's
/// extension is replaced and a name without one gets it; Save and Save As name the command that
/// writes a `.ait`.
#[test]
fn save_as_template_names_its_file_after_the_template_format() {
let mut s = rich();
for (name, format, saved) in [
("card.png", None, "card.vctemplate"),
("card.vectorcraft", None, "card.vctemplate"),
("card.ait", Some("template"), "card.vctemplate"),
("card.pdf", Some("ait"), "card.ait"),
("card v1.2", None, "card v1.2.vctemplate"),
] {
let (typed, want) = (tmp(&format!("named/{name}")), tmp(&format!("named/{saved}")));
let _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap());
let _ = std::fs::remove_file(&typed);
let _ = std::fs::remove_file(&want);
let r = s.execute("file.saveAsTemplate", &json!({"path": typed, "format": format})).unwrap();
assert_eq!(r["path"], want.as_str(), "{name}: {r}");
let bytes = std::fs::read(&want).unwrap_or_else(|e| panic!("{name}: {saved} not written: {e}"));
let ait = saved.ends_with(".ait");
assert_eq!(bytes.starts_with(b"%PDF"), ait, "{name}");
assert!(ait || vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "{name}: a native template");
assert!(!std::path::Path::new(&typed).exists(), "{name}: nothing written under the typed name");
}
for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp("card.ait")}))] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}");
}
}
#[test]
fn standards_and_screens_leave_the_editing_data_out() {
let mut s = rich();
@@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone()
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
}
#[test]
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
let mut s = session(40.0, 30.0, 1);
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
let on = png(&mut s, json!({"format": "png"}));
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
assert!(png_chunk(&off, b"sRGB").is_none());
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
let r = s.execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
}
#[test]
fn formats_list_the_raster_options() {
let mut s = Session::new();
@@ -217,6 +232,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
}
}
#[test]
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
// While another thread keeps exporting a document whose image can't be decoded (two exports at
// once, or the canvas drawing such a document), PNG exports of art with no image report
// nothing, and every export on the other thread still reports its own image.
use std::sync::atomic::{AtomicBool, Ordering};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im = ImageObject {
key: "broken".into(),
width: 2,
height: 1,
xf: vectorcraft_geom::Affine::scale(20.0),
link: None,
placement: Default::default(),
};
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let mut s = session(400.0, 400.0, 1);
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
let stop = AtomicBool::new(false);
let (tx, rx) = std::sync::mpsc::channel();
// Nothing in the scope panics, so the other thread is always told to stop.
let (theirs, ours) = std::thread::scope(|scope| {
let other = scope.spawn(|| {
let mut warned = vec![];
while !stop.load(Ordering::Relaxed) {
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
warned.push(w.is_ok_and(|w| says_so(&w)));
let _ = tx.send(());
}
warned
});
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
let ours: Vec<Option<bool>> = (0..5)
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
.collect();
stop.store(true, Ordering::Relaxed);
(other.join().ok(), started.then_some(ours))
});
let theirs = theirs.expect("the other thread's exports");
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
}
#[test]
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
@@ -233,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() {
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
}
#[test]
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
let mut s = session(200.0, 400.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let boards = s.doc().unwrap().doc.artboards.len();
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
let img = rgba(&b64(&r));
assert_eq!((img.width(), img.height()), (40, 300));
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
// Twice the resolution, and a window that holds both art and empty page.
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
assert_eq!((img.width(), img.height()), (80, 20));
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
}
#[test]
fn export_rect_refuses_what_cannot_be_exported() {
let mut s = session(200.0, 400.0, 1);
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let bad = [
json!({"format": "png", "rect": [0, 0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, -5, 10]}),
json!({"format": "png", "rect": [0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, "a", 10]}),
json!({"format": "png", "rect": "20,40,40,300"}),
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
];
for p in bad {
assert!(s.execute("document.export", &p).is_err(), "{p}");
}
}
@@ -91,3 +91,20 @@ fn a_chosen_artboard_s_svg_leaves_out_its_neighbours_art_in_a_shared_group() {
assert_eq!(third_only.matches("<path").count() + third_only.matches("<rect").count(), 1, "{third_only}");
assert!(third_only.contains(" C") || third_only.contains("<ellipse"), "{third_only}");
}
/// An object off a chosen artboard whose drop shadow reaches onto it is in that artboard's SVG.
#[test]
fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let x0 = s.doc().unwrap().doc.artboards[0].rect.x0;
let id = s.execute("shape.rectangle", &json!({"x": x0 - 30.0, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
let paths = |s: &mut Session| {
let svg = s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap();
let text = svg["text"].as_str().unwrap().to_string();
text.matches("<path").count() + text.matches("<rect").count()
};
assert_eq!(paths(&mut s), 0, "the plain rectangle is off the artboard");
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
}
+13 -2
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_doc,
flatten
),
@@ -259,13 +259,19 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
}
let src = s.doc()?.doc.clone();
let failed = vectorcraft_render::decode_failures();
let plan = plan(&src, &roots, &o, true)?;
let warning = super::rasterfx::undecoded_warning(failed);
let options = serde_json::to_value(&o).unwrap_or_default();
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
}
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options }))
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
// ---------- presets ----------
@@ -818,7 +824,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
let cmyk = src.color_mode == ColorMode::Cmyk;
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
vectorcraft_doc::cancel::check()?;
for g in overlapping(&reaches) {
vectorcraft_doc::cancel::check()?;
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
let group = g.iter().any(|&i| see_through[i]).then(|| {
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
@@ -950,6 +959,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
);
let mut r = vectorcraft_render::Renderer::new();
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
// The drawing stops early when cancelled: this image is not whole.
vectorcraft_doc::cancel::check()?;
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
// Without transparency the clip alone shapes the image: it is opaque, over white (its
// edge pixels too).
+62 -30
View File
@@ -114,9 +114,12 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
fonts
}
/// The fonts (family, style, version) of the type of `d` that prints on pages showing `areas`
/// (document space): visible type on `layers` (the layers that print) but not on a template layer
/// (of area type and type on a path that don't fit, the runs that their layout draws),
/// The type of `d` that prints on pages showing `areas` (document space): the fonts (family, style,
/// version) it is set in, and the ids of the area type and type on a path among the objects of the
/// document whose text doesn't fit (not those in the art of symbols, opacity masks, patterns,
/// brushes or placed documents).
/// The type that prints is visible type on `layers` (the layers that print) but not on a template
/// layer (of area type and type on a path that don't fit, the runs that their layout draws),
/// where what it paints ([`vectorcraft_render::painted_bounds`], its effects applied) meets an area
/// inside the clipping paths around it, the type in the opacity masks and patterns of the objects
/// that print and in the pieces their brushes draw, and the type of the symbols that their
@@ -125,27 +128,45 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
/// fills or strokes of its own, are taken with it when what it paints meets an area, and so is the
/// art of a placed document (`d` as [`Document::with_placed_art`] gives it, for that art's
/// resources).
pub(super) fn printed_fonts(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> BTreeSet<UsedFont> {
let mut walk = Printed { d, fonts: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> (BTreeSet<UsedFont>, BTreeSet<NodeId>) {
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
for l in layers {
walk.node(l, Some(areas));
}
walk.fonts
(walk.fonts, walk.overflow)
}
/// The walk of [`printed_fonts`].
/// The warning of an export that draws `layers` of `d` over `areas` (document space; `None`: the
/// whole of them) about the area type and type on a path that it draws and whose text doesn't fit,
/// found as [`printed_type`] finds them for Print: visible objects of the document that aren't on
/// a template layer.
pub(super) fn export_overflow_warning(d: &Document, layers: &[Arc<Node>], areas: Option<&[Rect]>) -> Option<String> {
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
for l in layers {
walk.node(l, areas);
}
let overflow: Vec<NodeId> = walk.overflow.into_iter().collect();
super::typecmd::export_overflow_warning(&overflow)
}
/// The walk of [`printed_type`].
struct Printed<'a> {
d: &'a Document,
/// Whether the walk is among the objects of the document, not in art drawn for one.
own: bool,
fonts: BTreeSet<UsedFont>,
/// The objects of the document whose text doesn't fit.
overflow: BTreeSet<NodeId>,
/// The symbols and patterns whose art was walked.
symbols: BTreeSet<String>,
patterns: BTreeSet<String>,
}
impl Printed<'_> {
/// Adds the fonts of `n` and its descendants that print: those whose bounds meet one of `areas`,
/// or with `None` (in the art of a symbol, opacity mask, pattern or placed document, in brush
/// pieces, or in a live object) all of them but those outside the clipping path of a clip group.
/// Adds the fonts of `n` and its descendants that print, and the ids of those that don't fit
/// while [`Printed::own`] is set: those whose bounds meet one of `areas`, or with `None` (in the
/// art of a symbol, opacity mask, pattern or placed document, in brush pieces, or in a live
/// object) all of them but those outside the clipping path of a clip group.
fn node(&mut self, n: &Node, areas: Option<&[Rect]>) {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return;
@@ -162,9 +183,9 @@ impl Printed<'_> {
}
// The art that its opacity mask, pattern paints and brushed strokes draw.
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
let runs = match &n.kind {
let (runs, overflows) = match &n.kind {
NodeKind::Text(t) => drawn_runs(t),
_ => vec![],
_ => (vec![], false),
};
let items: Vec<&AppearanceItem> =
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
@@ -180,7 +201,7 @@ impl Printed<'_> {
let brushed = vectorcraft_brush::has_brush(n);
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
if let Some(m) = mask {
self.node(&m.art, None);
self.art(&m.art);
}
let d = self.d;
for name in patterns {
@@ -188,19 +209,22 @@ impl Printed<'_> {
&& let Some(p) = d.pattern(name)
{
for art in &p.art {
self.node(art, None);
self.art(art);
}
}
}
// The pieces its brushes draw along the path (art and pattern brushes leave type out).
if brushed {
for piece in vectorcraft_brush::node_pieces(d, n).unwrap_or_default() {
self.node(&piece, None);
self.art(&piece);
}
}
}
match &n.kind {
NodeKind::Text(_) => {
if overflows && self.own {
self.overflow.insert(n.id);
}
for r in runs {
self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()));
// Inline graphics draw their symbols' art.
@@ -212,7 +236,7 @@ impl Printed<'_> {
NodeKind::SymbolInstance { symbol, .. } => self.symbol(symbol),
NodeKind::PlacedDocument(p) => {
for art in p.art(n) {
self.node(&art, None);
self.art(&art);
}
}
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } if !moved => {
@@ -240,10 +264,19 @@ impl Printed<'_> {
if self.symbols.insert(name.to_string())
&& let Some(s) = d.symbols.iter().find(|s| s.name == name)
{
self.node(&s.art, None);
self.art(&s.art);
}
}
/// Adds the fonts of `art`, drawn for an object of the document but not one of its objects (the
/// art of a symbol, opacity mask, pattern or placed document, or a brush piece), with
/// [`Printed::node`]'s `None`.
fn art(&mut self, art: &Node) {
let own = std::mem::replace(&mut self.own, false);
self.node(art, None);
self.own = own;
}
/// Whether what `n` paints, its effects applied, meets one of `areas`.
fn meets(&self, n: &Node, areas: &[Rect]) -> bool {
vectorcraft_render::painted_bounds(self.d, n).is_some_and(|b| areas.iter().any(|r| b.intersect(*r).area() > 0.0 || r.contains_rect(b)))
@@ -256,14 +289,15 @@ impl Printed<'_> {
}
}
/// The runs of `t` that print: all of them, or of area type and type on a path that don't fit, the
/// runs that their layout draws glyphs or inline graphics of.
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> Vec<&TextRun> {
/// The runs of `t` that print, and whether its text doesn't fit: all of them, or of area type and
/// type on a path that don't fit, the runs that their layout draws glyphs or inline graphics of.
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) {
let drawn: Option<BTreeSet<usize>> = (!matches!(t.kind, TextKind::Point))
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
.filter(|lay| lay.overflow)
.map(|lay| lay.glyphs.iter().map(|g| g.run).chain(lay.inlines.iter().map(|i| i.run)).collect());
t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect()
let runs = t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect();
(runs, drawn.is_some())
}
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
@@ -441,16 +475,14 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
}
});
if !texts.is_empty() {
let saved = !d.is_dirty();
let doc = std::sync::Arc::make_mut(&mut d.doc);
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
d.change_unsaved(|doc| {
let doc = std::sync::Arc::make_mut(doc);
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
}
}
}
if saved {
d.mark_saved();
}
});
}
d.revision += 1;
}
+782 -38
View File
@@ -11,9 +11,10 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
Appearance, CharStyle, Document, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, TickLength, ValueAxisSide,
Appearance, CharStyle, ColumnDesign, ColumnType, Document, Fractions, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind,
TextObject, TickLength, ValueAxisSide,
};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
use super::typecmd::refresh_bounds;
use super::*;
@@ -56,6 +57,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
design
),
cmd!(
"graph.column",
"Column…",
["Object", "Graph"],
None,
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
has_selection,
set_column
),
cmd!(
"graph.marker",
"Marker…",
@@ -82,20 +92,42 @@ impl Cell {
}
}
/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside).
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut parts = vec![];
let mut quoted = false;
let mut start = 0;
for (i, ch) in s.char_indices() {
if ch == '"' {
quoted = !quoted;
} else if ch == sep && !quoted {
parts.push(s.get(start..i).unwrap_or_default());
start = i + ch.len_utf8();
}
}
parts.push(s.get(start..).unwrap_or_default());
parts
}
/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed.
fn cell(c: &str) -> Cell {
let c = c.trim();
match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) {
Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true },
None => Cell { text: c.trim_matches('"').to_string(), quoted: false },
}
}
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
let rows: Vec<Vec<Cell>> = csv
.lines()
// Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes.
let rows: Vec<Vec<Cell>> = split_unquoted(csv, '\n')
.into_iter()
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.map(|l| {
let sep = if l.contains('\t') { '\t' } else { ',' };
l.split(sep)
.map(|c| {
let c = c.trim();
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
Cell { text: c.trim_matches('"').to_string(), quoted }
})
.collect()
let tabs = split_unquoted(l, '\t');
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
parts.into_iter().map(cell).collect()
})
.collect();
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
@@ -192,7 +224,9 @@ fn drop_empty_rows(g: &mut GraphSpec) {
fn fmt_value(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
// Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so
// `{:.0}` prints its exact digits.
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
} else {
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
}
@@ -287,6 +321,8 @@ struct Gen<'a> {
out: Vec<Arc<Node>>,
/// The labels' kept character styles.
styles: &'a vectorcraft_doc::LabelStyles,
/// What the column designs may still add.
columns: Allowance,
}
/// What a graph label shows, for its kept character style.
@@ -373,10 +409,26 @@ impl Gen<'_> {
/// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint.
fn marker(&mut self, design: Option<&Arc<Node>>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc<Node> {
match design.and_then(|a| fit_design(self.d, a, cell)) {
Some(n) => n,
Some(n) => self.design_copy(n),
None => self.path(shapes::rectangle(cell), fill, stroke, w),
}
}
/// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`].
fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
let fitted = match column {
Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell),
_ => None,
};
match fitted {
Some(n) => self.design_copy(n),
None => self.marker(marker, cell, paint),
}
}
/// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the
/// paths directly inside its group and the design keeps its own paint.
fn design_copy(&mut self, n: Arc<Node>) -> Arc<Node> {
if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n }
}
/// A series group. Empty series are omitted by the caller; the index is stored on the group
/// so later paint edits find the series without counting siblings.
fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
@@ -471,24 +523,260 @@ fn marker_cost(g: &GraphSpec, designs: &[Option<Arc<Node>>]) -> usize {
designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add)
}
/// The frame graph design `art` is drawn around: its backmost object's bounds (the whole art's when the design is a
/// single object). `None` for art with no area.
fn design_frame(art: &Node) -> Option<Rect> {
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
let group = matches!(art.kind, NodeKind::Group { .. });
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
backmost.or_else(|| art.geometric_bounds().filter(usable))
}
/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is
/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it.
/// Fresh ids. `None` for art with no area.
fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
let group = matches!(art.kind, NodeKind::Group { .. });
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
let frame = backmost.or_else(|| art.geometric_bounds().filter(usable))?;
let a = Affine::translate(cell.origin().to_vec2())
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
* Affine::translate(-frame.origin().to_vec2());
let a = fit_affine(design_frame(art)?, cell);
// Strokes, effects and pattern tiles scale with the art.
let art = super::place::transformed(art.clone(), a);
Some(Arc::new(d.reid(&art)))
}
/// Build the graph's children.
/// The map that puts a design's frame `frame` on `cell` ([`fit_design`]).
fn fit_affine(frame: Rect, cell: Rect) -> Affine {
Affine::translate(cell.origin().to_vec2())
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
* Affine::translate(-frame.origin().to_vec2())
}
/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy).
const COLUMN_BUDGET: usize = 200_000;
/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of
/// those guides.
struct ColumnArt {
options: ColumnDesign,
art: Node,
guides: Vec<Rect>,
}
/// `art` without the paths made into guides, at any depth.
fn without_guides(art: &Node) -> Node {
let mut n = art.clone();
if let Some(ch) = n.children_mut() {
let old = std::mem::take(ch);
*ch = old.iter().filter(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })).map(|c| Arc::new(without_guides(c))).collect();
}
n
}
/// Each series' column design, when the document has the design.
fn column_designs(d: &Document, g: &GraphSpec) -> Vec<Option<ColumnArt>> {
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
(0..nser)
.map(|s| {
let options = g.series_columns.get(s)?.as_ref()?;
let art = &d.graph_designs.iter().find(|x| x.name == options.design)?.art;
let mut guides = vec![];
art.walk(&mut |n| {
if matches!(n.kind, NodeKind::Path { guide: true, .. })
&& let Some(b) = n.geometric_bounds()
{
guides.push(b);
}
});
Some(ColumnArt { options: options.clone(), art: without_guides(art), guides })
})
.collect()
}
/// The value one copy of a repeating design stands for, when the design repeats and the value is usable.
fn repeat_each(c: &ColumnDesign) -> Option<f64> {
(c.column_type == ColumnType::Repeating && c.each.is_finite() && c.each > 0.0).then_some(c.each)
}
/// The objects a graph's column designs may still add (out of [`COLUMN_BUDGET`]); `short` once a copy needed more than
/// was left, and the graph is then drawn with plain columns.
struct Allowance {
left: usize,
short: bool,
}
impl Allowance {
fn new() -> Self {
Self { left: COLUMN_BUDGET, short: false }
}
/// Spend `n` objects, when that many are left.
fn take(&mut self, n: usize) -> bool {
match self.left.checked_sub(n) {
Some(rest) => {
self.left = rest;
true
}
None => {
self.short = true;
false
}
}
}
}
/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies
/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above
/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per
/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain.
fn column_art(
d: &mut Document,
c: &ColumnArt,
rect: Rect,
(horizontal, far_low): (bool, bool),
per_unit: f64,
left: &mut Allowance,
) -> Option<Arc<Node>> {
let frame = design_frame(&c.art)?;
let length = |r: Rect| if horizontal { r.width() } else { r.height() };
let thickness = |r: Rect| if horizontal { r.height() } else { r.width() };
if length(rect) <= 1e-9 || !length(rect).is_finite() {
return None;
}
let cost = c.art.count();
// A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when
// `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more).
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None };
let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| {
let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?;
// The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed
// stroke counts only in its own object's box.
let mut b = vectorcraft_render::painted_bounds(d, &art);
art.walk(&mut |n| {
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n));
}
});
let b = b.or_else(|| art.geometric_bounds())?;
let keep = match (along_x, keep_low) {
(true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1),
(true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1),
(false, true) => Rect::new(b.x0, b.y0, b.x1, at.max(b.y0)),
(false, false) => Rect::new(b.x0, at.min(b.y1), b.x1, b.y1),
};
Some(Arc::new(super::rasterfx::clip_group(d, shapes::rectangle(keep), FillRule::NonZero, art)))
};
// The part of the column from `a` to `b` points out from its zero end.
let piece = |a: f64, b: f64| match (horizontal, far_low) {
(true, true) => Rect::new(rect.x1 - b, rect.y0, rect.x1 - a, rect.y1),
(true, false) => Rect::new(rect.x0 + a, rect.y0, rect.x0 + b, rect.y1),
(false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a),
(false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b),
};
let mut parts = vec![];
match c.options.column_type {
ColumnType::Uniform => {
// Scaled both ways to the column's length, centered across it.
let t = thickness(frame) * length(rect) / length(frame) / 2.0;
let cell = if horizontal {
Rect::new(rect.x0, rect.center().y - t, rect.x1, rect.center().y + t)
} else {
Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1)
};
parts.push(fit(d, cell, left)?);
}
ColumnType::Repeating => {
// When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design
// is drawn vertically scaled.
let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else {
parts.push(fit(d, rect, left)?);
return Some(Arc::new(Node::group(d.alloc_id(), parts)));
};
// The whole copies, and the points of the column left for the last one (none under a millionth of a point).
let whole = ((length(rect) + 1e-6) / unit).floor();
let rest = length(rect) - whole * unit;
// Each copy is spent from `left`, which stops the loop.
for i in 0..whole as usize {
parts.push(fit(d, piece(i as f64 * unit, (i + 1) as f64 * unit), left)?);
}
if rest > 1e-6 {
let start = whole * unit;
match c.options.fractions {
Fractions::Scale => parts.push(fit(d, piece(start, start + rest), left)?),
Fractions::Chop => {
// The last copy cut off where the column ends, and nowhere else.
let cut = match (horizontal, far_low) {
(true, true) => (true, false, rect.x0),
(true, false) => (true, true, rect.x1),
(false, true) => (false, false, rect.y0),
(false, false) => (false, true, rect.y1),
};
parts.push(clipped(d, fit_affine(frame, piece(start, start + unit)), cut, left)?);
}
}
}
}
ColumnType::Sliding => match slide(frame, &c.guides, rect, horizontal) {
Some((kept, stretched, at)) => {
// Each copy cut at the guide, on its own side.
parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?);
parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?);
}
None => parts.push(fit(d, rect, left)?),
},
ColumnType::Vertical => parts.push(fit(d, rect, left)?),
}
Some(Arc::new(Node::group(d.alloc_id(), parts)))
}
/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first
/// horizontal guide in `guides` inside the frame (in a bar, right of the first vertical one), scaled in proportion and
/// put at the rect's top (in a bar, its right end), the value end for values above zero; the map of the part below (left
/// of) it, stretched or compressed to fill the rest; and where the guide lands (y, or x in a bar). Both maps put the guide there. `None` without such a guide, or when the column is too
/// short for the part kept in proportion.
fn slide(frame: Rect, guides: &[Rect], rect: Rect, horizontal: bool) -> Option<(Affine, Affine, f64)> {
// A horizontal line (in a bar, a vertical one): longer than it is across, and at most a hundredth of the frame across.
let inside = |g: &&Rect| {
if horizontal {
g.width() < g.height() && g.width() <= frame.width() / 100.0 && g.center().x > frame.x0 && g.center().x < frame.x1
} else {
g.height() < g.width() && g.height() <= frame.height() / 100.0 && g.center().y > frame.y0 && g.center().y < frame.y1
}
};
let g = guides.iter().find(inside)?.center();
if horizontal {
let s = rect.height() / frame.height();
let (kept, stretched) = (frame.x1 - g.x, g.x - frame.x0);
let rest = rect.width() - kept * s;
if !(rest.is_finite() && rest > 1e-9) {
return None;
}
let k = rest / stretched;
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x1 - frame.x1 * s, rect.y0 - frame.y0 * s]);
let stretched_map = Affine::new([k, 0.0, 0.0, s, rect.x0 - frame.x0 * k, rect.y0 - frame.y0 * s]);
Some((kept_map, stretched_map, rect.x1 - kept * s))
} else {
let s = rect.width() / frame.width();
let (kept, stretched) = (g.y - frame.y0, frame.y1 - g.y);
let rest = rect.height() - kept * s;
if !(rest.is_finite() && rest > 1e-9) {
return None;
}
let k = rest / stretched;
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x0 - frame.x0 * s, rect.y0 - frame.y0 * s]);
let stretched_map = Affine::new([s, 0.0, 0.0, k, rect.x0 - frame.x0 * s, rect.y1 - frame.y1 * k]);
Some((kept_map, stretched_map, rect.y0 + kept * s))
}
}
/// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it,
/// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects.
fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let column_looks = column_designs(d, g);
match generate_with(d, g, &column_looks) {
Some(out) => out,
None => generate_with(d, g, &[]).unwrap_or_default(),
}
}
/// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects.
fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnArt>]) -> Option<Vec<Arc<Node>>> {
let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width);
// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all
// when the designs would draw more than MARKER_BUDGET objects.
@@ -497,7 +785,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
designs.clear();
}
let design = |s: usize| designs.get(s).and_then(Option::as_ref);
let mut b = Gen { d, out: vec![], styles: &g.label_styles };
let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref);
let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() };
// The labels' sizes (9 pt unless formatted), which their spacing follows.
let (vs, cs, ls) =
(label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend));
@@ -514,6 +803,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
// Series drawn as columns or bars, which column designs apply to.
let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar);
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it.
let mut right_edge = r.x1;
@@ -735,6 +1026,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let (lo, hi, _) = scale(a);
vpos(a, 0.0f64.clamp(lo, hi))
};
// Plot points per unit of value along value axis `a`.
let per_unit = |a: usize| {
let (lo, hi, _) = scale(a);
(if horizontal { r.width() } else { r.height() }) / (hi - lo)
};
let mut axes = vec![];
// Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right
// one shows the right scale).
@@ -940,10 +1236,24 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let Some(v) = cell(c, s) else { continue };
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
let rect = bar_rect(a, bar, zero(axis(s)), vpos(axis(s), v));
let (v0, v1) = (zero(axis(s)), vpos(axis(s), v));
let rect = bar_rect(a, bar, v0, v1);
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
shadowed.push((s, n.clone()));
// A column drawn with a design casts no shadow.
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) {
Some(n) => {
// A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too.
if let Some(art) = n.geometric_bounds() {
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
}
n
}
None => {
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
shadowed.push((s, n.clone()));
n
}
};
if let Some(items) = series.get_mut(s) {
items.push(n);
}
@@ -971,11 +1281,22 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
let rect = bar_rect(a, w, v0, v1);
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
// A segment of 0 has no area to shade.
if v != 0.0 {
shadowed.push((s, n.clone()));
}
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
Some(n) => {
if let Some(art) = n.geometric_bounds() {
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
}
n
}
None => {
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
// A segment of 0 has no area to shade.
if v != 0.0 {
shadowed.push((s, n.clone()));
}
n
}
};
if let Some(items) = series.get_mut(s) {
items.push(n);
}
@@ -1057,7 +1378,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
for (s, label, w) in row {
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line;
let paint = mark(*s, true, Paint::None, 0.0);
let swatch = b.marker(design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
let swatch =
b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
if let Some(items) = series.get_mut(*s) {
items.push(swatch);
}
@@ -1079,10 +1401,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
y += ls * 1.8 * label.split('|').count() as f64;
let y = row_y;
// Series drawn with markers show their marker design in the legend.
// Series drawn with markers show their marker design in the legend, and series drawn with a column design
// that design.
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line;
let paint = mark(s, true, Paint::None, 0.0);
items.push(b.marker(design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false));
}
}
@@ -1129,7 +1452,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let grp = b.group("Legend", labels);
b.out.push(grp);
}
b.out
(!b.columns.short).then_some(b.out)
}
fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
@@ -1475,11 +1798,16 @@ fn design_names(d: &Document) -> Vec<String> {
d.graph_designs.iter().map(|x| x.name.clone()).collect()
}
/// Whether graph `g` draws a series with design `name`, as its marker or its columns.
fn draws_with(g: &GraphSpec, name: &str) -> bool {
g.series_markers.iter().flatten().any(|m| m == name) || g.series_columns.iter().flatten().any(|c| c.design == name)
}
/// Regenerate the graphs whose series use design `name` (it was added or removed).
fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> {
let mut ids = vec![];
d.walk(|n| {
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) {
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, name)) {
ids.push(n.id);
}
});
@@ -1497,6 +1825,7 @@ fn unused_designs(d: &Document) -> Vec<String> {
d.walk(|n| {
if let Some(g) = n.extras().graph.as_deref() {
used.extend(g.series_markers.iter().flatten().cloned());
used.extend(g.series_columns.iter().flatten().map(|c| c.design.clone()));
}
});
d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect()
@@ -1570,7 +1899,7 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut graphs = vec![];
d.walk(|n| {
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) {
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, &old)) {
graphs.push(n.id);
}
});
@@ -1579,6 +1908,9 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) {
*m = new.clone();
}
for c in g.series_columns.iter_mut().flatten().filter(|c| c.design == old) {
c.design = new.clone();
}
}
}
Ok(())
@@ -1669,6 +2001,74 @@ fn set_marker(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0 }))
}
fn set_column(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.column";
let id = target(s, p, C)?;
let mut spec = spec_of(s, id)?;
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
let picked = picked_series(s, p, id, count, C)?;
let names = design_names(&s.doc()?.doc);
let all: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked.clone() };
// The column design the picked series (all of them with none picked) share; a design deleted since doesn't count,
// as those series draw plain columns.
let used: Vec<Option<&ColumnDesign>> =
all.iter().map(|i| spec.series_columns.get(*i).and_then(Option::as_ref).filter(|c| names.contains(&c.design))).collect();
let shared = used.first().copied().flatten().filter(|c| used.iter().all(|x| *x == Some(*c))).cloned();
let Some(v) = p.get("design") else {
let options = shared.clone().unwrap_or_default();
return Ok(json!({
"designs": names,
"design": shared.map(|c| c.design),
"columnType": options.column_type.id(),
"each": options.each,
"fractions": options.fractions.id(),
}));
};
let design = match v {
Value::Null => None,
Value::String(n) if names.contains(n) => Some(n.clone()),
Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))),
_ => return Err(bad(C, "`design` is a design name, or null for plain columns")),
};
let mut options = shared.unwrap_or_default();
if let Some(t) = p.get("columnType") {
options.column_type =
t.as_str().and_then(ColumnType::parse).ok_or_else(|| bad(C, "`columnType` is vertical, uniform, repeating or sliding"))?;
}
if let Some(e) = p.get("each") {
options.each =
e.as_f64().filter(|e| e.is_finite() && *e > 0.0).ok_or_else(|| bad(C, "`each` is the value one design stands for, more than 0"))?;
}
if let Some(f) = p.get("fractions") {
options.fractions = f.as_str().and_then(Fractions::parse).ok_or_else(|| bad(C, "`fractions` is chop or scale"))?;
}
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
spec.series_columns.truncate(count);
spec.series_columns.resize(count, None);
for i in all {
if let Some(c) = spec.series_columns.get_mut(i) {
*c = design.clone().map(|design| ColumnDesign { design, ..options.clone() });
}
}
while spec.series_columns.last().is_some_and(Option::is_none) {
spec.series_columns.pop();
}
// Drawn once on a copy of the document, to refuse designs past the budget before the edit.
let mut probe = (*s.doc()?.doc).clone();
let looks = column_designs(&probe, &spec);
if generate_with(&mut probe, &spec, &looks).is_none() {
return Err(bad(C, "the design is too big to draw in every column of this graph"));
}
s.edit("Graph Column", |d, sel| {
regenerate(d, id, spec)?;
if !reselect.is_empty() {
reselect_series(d, sel, id, &reselect);
}
Ok(())
})?;
Ok(json!({ "id": id.0 }))
}
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.setType";
let id = target(s, p, C)?;
@@ -1896,10 +2296,20 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, GraphKind, GraphSpec, NodeKind};
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
use crate::{NodeId, Session};
#[test]
fn fmt_value_does_not_saturate_beyond_i64() {
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
assert_eq!(super::fmt_value(42.0), "42");
assert_eq!(super::fmt_value(-0.0), "0");
assert_eq!(super::fmt_value(2.5), "2.5");
}
fn graph(s: &mut Session, ty: &str) -> NodeId {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
NodeId(
@@ -2358,6 +2768,30 @@ mod tests {
assert_eq!(cells, g.cells(), "{csv}");
}
#[test]
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
let r = s
.execute(
"graph.create",
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
)
.unwrap();
let id = r["id"].as_u64().unwrap();
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(before["series"], series);
assert_eq!(before["categories"], categories);
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
@@ -3307,6 +3741,16 @@ mod tests {
assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(design_fill(&s), painted);
// A design of one path too.
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("graph.design", &json!({"new": "Square"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Square"})).unwrap();
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]);
}
#[test]
@@ -3356,6 +3800,306 @@ mod tests {
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
}
/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle
/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`.
fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) {
let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| {
s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()
};
let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)];
ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top));
if guide {
let line =
s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [line]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
ids.push(line);
}
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": name})).unwrap();
}
/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero
/// line at y = 300.
fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId {
let rows: Vec<Vec<f64>> = values.iter().map(|v| vec![*v]).collect();
let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows});
let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap());
s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap();
id
}
/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it.
fn frames(n: &vectorcraft_doc::Node) -> Vec<vectorcraft_geom::Rect> {
n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect()
}
fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool {
[a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6)
}
#[test]
fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = one_series(&mut s, "column", &[35.0]);
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}");
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap();
// 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value.
let column = series_parts(&s, id, 0)[0].clone();
let parts = column.children().unwrap();
assert_eq!(parts.len(), 4, "3.5 designs");
let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1];
let copies = frames(&column);
assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}");
let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) };
assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy");
let (clip, copy) =
(children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap());
assert_eq!(clip.y0, 160.0, "cut off at the value");
// Its sides and its foot, strokes included, are not cut.
assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}");
// Nor is the brush art of a brushed path inside a group of the design.
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0));
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
for item in &mut look.items {
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item {
st.brush = Some("Wide".into());
}
}
let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look);
let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]);
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) });
let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() };
let g = GraphSpec {
rows: vec![vec![35.0]],
axis_min: Some(0.0),
axis_max: Some(50.0),
series_columns: vec![Some(options)],
..GraphSpec::default()
};
let out = super::generate(&mut d, &g);
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
let last = column.children().unwrap().last().unwrap().clone();
let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) };
let clip = children[0].geometric_bounds().unwrap();
let mut reach = None;
children[1].walk(&mut |n| {
if vectorcraft_brush::has_brush(n) {
reach = vectorcraft_render::painted_bounds(&d, n);
}
});
let reach = reach.unwrap();
assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}");
// Scale: the last copy half as tall instead.
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap();
let column = series_parts(&s, id, 0)[0].clone();
let copies = frames(&column);
assert_eq!(copies.len(), 4);
assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}");
let c = &spec(&s, id).series_columns[0];
assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale)));
// A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line.
let bars = one_series(&mut s, "bar", &[-15.0]);
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap();
let bar = series_parts(&s, bars, 0)[0].clone();
let copies = frames(&bar);
// The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250.
let (zero, unit) = (250.0, 3.0);
assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}");
assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}");
}
#[test]
fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 10 wide and 20 tall, the guide 5 points from the top.
save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true);
let id = one_series(&mut s, "column", &[35.0, 20.0]);
s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap();
let parts = series_parts(&s, id, 0);
for (column, value_end) in parts.iter().zip([160.0, 220.0]) {
// The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands
// 12 points (2.4 times 5) below the value end.
let halves = column.children().unwrap();
assert_eq!(halves.len(), 2, "{halves:?}");
let half = |i: usize| {
let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) };
(children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec())
};
let ((above, kept), (below, stretched)) = (half(0), half(1));
let at = value_end + 12.0;
assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}");
assert_eq!(kept.len(), 3, "the guide is not drawn");
// Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end.
let top = kept[1].geometric_bounds().unwrap();
assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}");
// Below it the design is stretched down to the zero line.
let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap());
assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}");
assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}");
}
// Vertically Scaled stretches the whole design, the top part included.
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap();
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
// Without a guide a sliding design is scaled vertically too.
save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false);
s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap();
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
// Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before
// the line, are passed over.
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
let mut ids = vec![
id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()),
id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()),
];
let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap());
let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap());
let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap());
s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
ids.extend([square, tick, line]);
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": "Boxed"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap();
let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec();
let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap();
assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}");
}
#[test]
fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = one_series(&mut s, "column", &[35.0, 20.0]);
s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap();
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap();
for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) {
let frame = frames(column)[0];
assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}");
assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6);
}
assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column");
// A design twice as wide as it is tall reaches past the plot, and the legend starts right of it.
save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false);
let wide = one_series(&mut s, "column", &[20.0, 35.0]);
s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap();
let parts = series_parts(&s, wide, 0);
let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap());
assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns");
// The legend swatch shows the design, its frame filling the 8-point square.
let swatch = series_parts(&s, id, 0).last().unwrap().clone();
let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap();
assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}");
// Painting the series leaves the design's paint alone.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
let mut red = false;
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000"));
assert!(!red);
}
#[test]
fn column_designs_survive_save_and_open_and_follow_their_design() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = graph(&mut s, "stackedColumn");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
// Every series takes the design; only the stacked column series draws it.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"});
s.execute("graph.column", &p).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch");
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]);
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none());
// Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again.
s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap();
assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token"));
assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([]));
s.execute("graph.design", &json!({"delete": "Token"})).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null());
s.execute("edit.undo", &json!({})).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })));
// Null goes back to plain columns.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap();
assert!(spec(&s, id).series_columns.is_empty());
}
#[test]
fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = one_series(&mut s, "column", &[35.0]);
for p in [
json!({"design": "Nope"}),
json!({"design": 3}),
json!({"design": "Coin", "columnType": "diagonal"}),
json!({"design": "Coin", "each": 0}),
json!({"design": "Coin", "each": -1}),
json!({"design": "Coin", "each": "10"}),
json!({"design": "Coin", "fractions": "round"}),
] {
let mut p = p;
p["id"] = json!(id.0);
assert!(s.execute("graph.column", &p).is_err(), "{p}");
}
assert!(spec(&s, id).series_columns.is_empty());
// 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns.
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001});
assert!(s.execute("graph.column", &p).is_err());
let mut g = spec(&s, id);
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop };
g.series_columns = vec![Some(options.clone())];
let mut d = (*s.doc().unwrap().doc).clone();
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each
// 0.1 is past the budget too.
let far = one_series(&mut s, "column", &[0.0, 0.0]);
s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap();
assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err());
assert!(spec(&s, far).series_columns.is_empty());
// A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas:
// 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy.
for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] {
g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })];
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
let column = s0.children().unwrap()[0].clone();
assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}");
assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}");
assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped");
}
}
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
NodeId(
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
+8 -2
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Vector Halftone…",
["Object"],
None,
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_selection,
halftone
)]
@@ -185,11 +185,13 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
let mut r = vectorcraft_render::Renderer::new();
let (w, h) = vectorcraft_render::region_pixels(region, scale);
let (w, h) = (w as usize, h as usize);
let failed = vectorcraft_render::decode_failures();
let px = if mono {
r.render_region(&tmp, region, scale, true).pixels
} else {
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
};
let warning = super::rasterfx::undecoded_warning(failed);
if px.len() < w * h * 4 {
return Err(EngineError::Other("the art could not be sampled".into()));
}
@@ -241,7 +243,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0, "dots": dots }))
let mut out = json!({ "id": id.0, "dots": dots });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
/// The dots of one screen over `bounds` as one path, with their count.
+191
View File
@@ -0,0 +1,191 @@
//! Window → SVG Interactivity: the JavaScript an object runs on each event in web output
//! (`svgInteractivity.setEvent`, `removeEvent`, `clearEvents`) and the JavaScript files the
//! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as
//! event attributes (`onclick="…"`) and the files as `<script xlink:href>` elements; SVG import
//! reads both back as text. The panel reads everything with `svgInteractivity.info`.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
use super::overprint::edit_counted;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "svgInteractivity.info",
"SVG Interactivity",
[],
None,
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
has_doc,
info
),
cmd!(
"svgInteractivity.setEvent",
"Add Event",
[],
None,
"{event: \"onclick\"|\"onmouseover\"|… (svgInteractivity.info's eventNames), script: string (the JavaScript; \"\" removes the event), ids?|id?} set the JavaScript an event runs on ids or the selection, replacing the event's script where it has one, as one undo step; SVG export writes it as the event attribute → {changed: objects}",
has_doc,
set_event
),
cmd!(
"svgInteractivity.removeEvent",
"Remove Selected Entry",
[],
None,
"{event, ids?|id?} remove an event and its JavaScript from ids or the selection, as one undo step → {changed: objects}",
has_doc,
remove_event
),
cmd!(
"svgInteractivity.clearEvents",
"Clear Events",
[],
None,
"{ids?|id?} remove every event from ids or the selection, as one undo step → {changed: objects}",
has_doc,
clear_events
),
cmd!(
"svgInteractivity.addFile",
"Add JavaScript File",
[],
None,
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked stays where it is; a document links at most 64 files → {files}",
has_doc,
add_file
),
cmd!(
"svgInteractivity.removeFile",
"Remove JavaScript File",
[],
None,
"{file} unlink a JavaScript file from the document → {files}",
has_doc,
remove_file
),
]
}
/// `p`'s `event`, one of [`SVG_EVENTS`] (any case).
fn event_param(p: &Value, cmd: &str) -> Result<&'static str> {
let e = str_param(p, "event").ok_or_else(|| bad(cmd, "give `event`, such as onclick"))?;
SVG_EVENTS
.iter()
.find(|n| n.eq_ignore_ascii_case(e.trim()))
.copied()
.ok_or_else(|| bad(cmd, format!("`event` must be one of {}", SVG_EVENTS.join(", "))))
}
/// The events of object `id` in `d` (none for a missing object).
fn events_of(d: &Document, id: NodeId) -> Vec<(String, String)> {
d.node(id).and_then(|n| n.extras().attrs.as_deref()).map(|a| a.events.clone()).unwrap_or_default()
}
/// Change the events of `ids` (or the selection) with `f`, as one undo step named `label`; an
/// object whose events `f` leaves as they were isn't changed → `{changed: objects}`.
fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&mut Vec<(String, String)>)) -> Result<Value> {
let ids = targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select the objects (or give their ids)"));
}
let changed = edit_counted(s, label, |d| {
let mut changed = 0;
for id in &ids {
let mut events = events_of(d, *id);
let before = events.clone();
f(&mut events);
if events != before
&& let Some(n) = d.node_mut(*id)
{
n.edit_attrs(|a| a.events = events);
changed += 1;
}
}
Ok(changed)
})?;
Ok(json!({ "changed": changed }))
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
let events = lists
.first()
.filter(|first| lists.iter().all(|l| l == *first))
.map(|l| l.iter().map(|(event, script)| json!({ "event": event, "script": script })).collect::<Vec<_>>());
Ok(json!({
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
"events": events,
"files": d.svg_scripts,
"eventNames": SVG_EVENTS,
}))
}
fn set_event(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgInteractivity.setEvent";
let event = event_param(p, C)?;
let script = p.get("script").map(|v| v.as_str().ok_or_else(|| bad(C, "`script` must be a string"))).transpose()?;
let script = script.ok_or_else(|| bad(C, "give `script`, the JavaScript the event runs (\"\" removes the event)"))?.trim().to_string();
edit_events(s, p, C, "Add Event", |events| {
if script.is_empty() {
events.retain(|(e, _)| e != event);
} else if let Some((_, js)) = events.iter_mut().find(|(e, _)| e == event) {
js.clone_from(&script);
} else {
events.push((event.to_string(), script.clone()));
}
})
}
fn remove_event(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgInteractivity.removeEvent";
let event = event_param(p, C)?;
edit_events(s, p, C, "Remove Selected Entry", |events| events.retain(|(e, _)| e != event))
}
fn clear_events(s: &mut Session, p: &Value) -> Result<Value> {
edit_events(s, p, "svgInteractivity.clearEvents", "Clear Events", Vec::clear)
}
/// `p`'s `file`, trimmed and not empty.
fn file_param(p: &Value, cmd: &str) -> Result<String> {
let f = str_param(p, "file").map(str::trim).filter(|f| !f.is_empty());
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
}
fn files(s: &Session) -> Result<Value> {
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
}
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgInteractivity.addFile";
let file = file_param(p, C)?;
let linked = &s.doc()?.doc.svg_scripts;
if !linked.contains(&file) {
if linked.len() >= MAX_SVG_SCRIPTS {
return Err(bad(C, format!("a document links at most {MAX_SVG_SCRIPTS} JavaScript files")));
}
s.edit("Add JavaScript File", |d, _| {
d.svg_scripts.push(file);
Ok(())
})?;
}
files(s)
}
fn remove_file(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgInteractivity.removeFile";
let file = file_param(p, C)?;
if !s.doc()?.doc.svg_scripts.contains(&file) {
return Err(bad(C, format!("{file:?} isn't linked")));
}
s.edit("Remove JavaScript File", |d, _| {
d.svg_scripts.retain(|f| *f != file);
Ok(())
})?;
files(s)
}
+37 -1
View File
@@ -5,7 +5,8 @@
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
//! `vectorcraft_doc::intertwine`).
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
//!
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
/// Replace Intertwine group `id` by a plain group of the art it draws
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
super::live::fix_ids(d, &mut g);
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
Ok(())
}
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
/// many. A live Intertwine is expanded before it can be ungrouped:
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
let mut groups = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if is_intertwine(c) {
groups.push(c.id);
}
});
}
}
for g in groups.iter().rev() {
expand_group(d, *g)?;
}
Ok(groups.len())
}
// ---------- Arrange ----------
#[derive(Clone, Copy, PartialEq)]
+45 -11
View File
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Artwork Bounds",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)",
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
has_doc,
artboard_fit_art
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Selected Art",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)}",
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
has_selection,
artboard_fit_sel
),
@@ -286,7 +286,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
}
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
match v {
Value::Number(n) => match n.as_u64() {
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
@@ -412,6 +412,9 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
if !st.doc.is_editable(id) {
return Err(bad(C, "the object is hidden or locked"));
}
if !in_isolation(&st.doc, st.isolation, id) {
return Err(bad(C, "the object is outside the isolated group"));
}
let layer = n.is_layer();
let rows = st.layer_rows.clone();
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
@@ -645,6 +648,11 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": lid.0 }))
}
/// Whether `id` lies inside the isolated group or layer (always, outside isolation mode).
fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
isolation.is_none_or(|iso| iso != id && doc.ancestry(id).is_some_and(|a| a.contains(&iso)))
}
/// The selection column: select a row's art.
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.selectAll";
@@ -652,7 +660,7 @@ fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
let add = bool_or(p, "add", false);
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
let ids: Vec<NodeId> = if !st.doc.is_editable(id) || !in_isolation(&st.doc, st.isolation, id) {
vec![]
} else if n.is_layer() {
n.layer_art(true)
@@ -826,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
@@ -890,17 +898,43 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
xf.is_finite().then_some(xf)
}
/// The artboard fits the art that exports draw: hidden objects and layers, guides and template
/// layers don't count.
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
/// template layers don't count. An artboard with no art on it stays as it is, and the command
/// fails when none has art. The fits are one undo step named Artboard Options.
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
let d = &s.doc()?.doc;
let indices =
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
let mut fits = vec![];
for i in indices {
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
fits.push((i, was, Rect::new(b.x0, b.y0, b.x0 + b.width().max(1.0), b.y0 + b.height().max(1.0))));
}
}
if fits.is_empty() {
return Err(EngineError::Other("no artwork".into()));
}
s.edit("Artboard Options", |d, _| {
for &(i, was, now) in &fits {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = now;
let id = a.id;
// Moved and not resized, it takes its guides along, as in Artboard Options.
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
d.move_artboard_guides(id, now.origin() - was.origin());
}
}
Ok(())
})?;
ok()
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let st = s.doc()?;
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+13 -2
View File
@@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
if ids.len() < 2 {
return Err(bad(C, "highlight two or more rows to merge"));
}
let into =
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
// The last highlighted layer that is not inside another highlighted row: with a layer and its
// sublayer both highlighted (Select All in the panel), the sublayer travels with its layer
// instead of becoming the target (#2383).
let roots: HashSet<NodeId> = row_roots(d, &ids).into_iter().collect();
let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer);
let into = ids
.iter()
.rev()
.copied()
.filter(is_layer)
.find(|id| roots.contains(id))
.or_else(|| ids.iter().rev().copied().find(is_layer))
.ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
let around = d.ancestry(into).unwrap_or_default();
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
+74 -20
View File
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Spine Segment",
[],
None,
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)",
has_doc,
spine_reshape_segment
),
@@ -238,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Gradient Mesh…",
["Object"],
None,
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}",
has_selection,
mesh_create
),
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
}
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
fn fix_ids(d: &mut Document, n: &mut Node) {
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
if n.id == NodeId(0) {
n.id = d.alloc_id();
}
@@ -402,16 +402,20 @@ const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
let mode = str_param(p, "spacing");
let value = num("value");
let sp = match mode {
Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor,
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32),
Some("steps") => match value {
Some(v) => steps("value", v)?,
None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?,
},
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)),
Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))),
None => {
if let Some(n) = num("steps") {
BlendSpacing::Steps(n as u32)
steps("steps", n)?
} else if let Some(d) = num("distance") {
BlendSpacing::Distance(d)
} else if bool_or(p, "smooth", false) {
@@ -657,7 +661,11 @@ fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
let perpendicular = bool_or(p, "perpendicular", false);
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
})?;
ok()
}
@@ -955,14 +963,15 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) })
}
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
let rows = f64_or(p, "rows", default.0 as f64);
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
let cols = f64_or(p, cols_key, default.1 as f64);
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
return Err(bad(cmd, "rows and cols must be 1..50"));
}
Ok((rows as u32, cols as u32))
Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32))
}
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1299,7 +1308,7 @@ fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.setMeshPoint";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let index = f64_req(p, "index", C)? as usize;
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
s.edit("Move Envelope Point", |d, _| {
@@ -1344,18 +1353,33 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
let mut out = vec![];
let mut index = None;
let mut refused = None;
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let Some(path) = outline_of(&n) else { continue };
let m = match n.appearance.fill_paint() {
// The points take the colours the gradient paints there.
Paint::Gradient(g) => {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
let m = if let NodeKind::Image(im) = &n.kind {
// An embedded image: the points take the picture's colours there.
match image_mesh(d, im, rows, cols) {
Ok(mut m) => {
m.highlight(app, highlight);
m
})
Some(m)
}
Err(why) => {
refused.get_or_insert(why);
continue;
}
}
} else {
let Some(path) = outline_of(&n) else { continue };
match n.appearance.fill_paint() {
// The points take the colours the gradient paints there.
Paint::Gradient(g) => {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
m.highlight(app, highlight);
m
})
}
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
}
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
};
let Some(mut m) = m else { continue };
if let Some(q) = at {
@@ -1371,7 +1395,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
out.push(*id);
}
if out.is_empty() {
return Err(bad(C, "select a path or compound path"));
return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image")));
}
sel.set(out.iter().copied());
Ok((out, index))
@@ -1383,6 +1407,31 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle
/// (placed with the image's transform), each point taking the average colour and opacity of the
/// area around it. Linked images are refused: the documentation allows embedded ones only.
fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result<GradientMesh, &'static str> {
if im.link.is_some() {
return Err("a linked image can't become a gradient mesh: embed it first");
}
let xf = im.xf.as_coeffs();
if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) {
return Err("the image has no usable size or position");
}
let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?;
let (w, h) = (im.width as f64, im.height as f64);
let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h);
if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) {
return Err("the image has no usable size or position");
}
let cols_n = cols as usize + 1;
let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::<Vec<_>>();
Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| {
let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]);
(Color::rgb8(pr, pg, pb), pa as f32 / 255.0)
}))
}
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
@@ -1422,7 +1471,12 @@ fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
if i < 0.0 {
return Err(bad(cmd, "index must be ≥ 0"));
}
Ok(i as usize)
Ok(whole(i, key, cmd)? as usize)
}
/// `x`, the value of `key`, when it is a whole number, and an error otherwise.
fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) }
}
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
+73 -30
View File
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
has_selection,
rasterize
),
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Object Mosaic…",
["Object"],
None,
"{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
"{columns=10, rows=10 (whole numbers, kept within 1..1000), width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
has_image,
object_mosaic
),
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Crop Image",
["Object"],
None,
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim), warning? (without trim: the image couldn't be decoded, as object.rasterize says)}",
has_image,
crop_image
),
@@ -168,6 +168,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// The whole number `p` gives for `key`, written `4` or `4.0`, or `default` when `p` gives none or null. A value that
/// is not a number gives the [`check_numbers`] error, and a fraction or a negative number gives
/// "`{key}` must be a whole number".
fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
check_numbers(p, &[key], cmd)?;
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(default) };
match (v.as_u64(), v.as_f64()) {
(Some(n), _) => Ok(n),
// A whole number past `u64::MAX` saturates to it.
(None, Some(x)) if x >= 0.0 && x.fract() == 0.0 => Ok(x as u64),
_ => Err(bad(cmd, format!("`{key}` must be a whole number"))),
}
}
/// Run a command's implementation without the enablement check or journaling.
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
@@ -210,8 +224,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
fn above(s: &mut Session, lock: bool) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let symbols = d.symbol_boxes();
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
// ancestor directly inside the layer.
// ancestor directly inside the layer. Symbol instances are measured by their art.
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
for id in &roots {
let Some(layer) = d.layer_of(*id) else { continue };
@@ -219,7 +234,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
let anc = d.ancestry(*id).unwrap_or_default();
let Some(pos) = anc.iter().position(|a| *a == layer) else { continue };
let top = anc.get(pos + 1).copied().unwrap_or(*id);
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds_with(&symbols)) else { continue };
let Some((_, idx, _)) = d.position(top) else { continue };
selected.entry(layer).or_default().push((idx, b));
}
@@ -239,7 +254,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
if under.is_empty() || roots.contains(&n.id) {
continue;
}
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
if n.visual_bounds_with(&symbols).is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
hits.insert(n.id);
}
}
@@ -335,13 +350,13 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let rx = bool_or(p, "reflectX", false);
let ry = bool_or(p, "reflectY", false);
let random = bool_or(p, "random", false);
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = bool_or(p, "copy", false);
let objects = super::object::transforms_objects(p);
if sh == 0.0 || sv == 0.0 {
return Err(bad("object.transformEach", "scale must be non-zero"));
}
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
let mut rng = Rng(whole_or(p, "seed", 0x9E37_79B9_7F4A_7C15, "object.transformEach")?.max(1));
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
// With no object to transform, or without a copy when both scales are 100% and there is no
@@ -352,8 +367,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
}
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
for id in &targets {
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
// Measure each object's geometric bounds first, with symbol instances measured by their art.
let symbols = d.symbol_boxes();
let boxes: Vec<Option<Rect>> = targets.iter().map(|id| d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols))).collect();
for (id, b) in targets.iter().zip(boxes) {
let Some(b) = b else { continue };
let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot);
if random {
k_sh = 100.0 + (sh - 100.0) * rng.next();
@@ -408,21 +426,24 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
}
}
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`.
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an
/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]).
fn render_png(
doc: &vectorcraft_doc::Document,
region: Rect,
scale: f64,
opts: &vectorcraft_render::RenderOptions,
color: RasterColorModel,
) -> Result<(Vec<u8>, u32, u32)> {
) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> {
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
}
let mut r = vectorcraft_render::Renderer::new();
let failed = vectorcraft_render::decode_failures();
let mut img = r.render_region_with(doc, region, scale, opts);
let warning = super::rasterfx::undecoded_warning(failed);
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning))
}
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
@@ -479,7 +500,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
.flatten()
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
let tmp = isolated_doc(&st.doc, nodes);
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
let (png, w, h, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
if roots.is_empty() {
return Err(bad(C, "nothing selected"));
}
@@ -497,7 +518,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0, "width": w, "height": h }))
let mut out = json!({ "id": id.0, "width": w, "height": h });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
fn has_image(s: &Session) -> std::result::Result<(), String> {
@@ -524,8 +549,8 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
let gray = bool_or(p, "gray", false);
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
@@ -634,7 +659,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
bare.visible = true;
let tmp = isolated_doc(&st.doc, vec![bare]);
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
let (png, w, h, warning) =
render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
s.edit("Crop Image", |d, _| {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
@@ -650,7 +676,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "id": id.0, "width": w, "height": h }))
let mut out = json!({ "id": id.0, "width": w, "height": h });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
@@ -664,9 +694,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
}
// Print rasters run to gigabytes decoded: the same allowance opening them has.
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
let reader = || {
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))
};
// The image's size as RGBA, read from its header, is held to that allowance before anything
// is decoded (#1717).
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
}
let mut reader = reader()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
reader.limits(limits);
@@ -863,9 +902,11 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let n = s.edit("Convert to Artboards", |d, sel| {
// Measure each object's geometric bounds first, with symbol instances measured by their art.
let symbols = d.symbol_boxes();
let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect();
let mut converted = vec![];
for id in &roots {
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
for (id, b) in boxes {
if b.width() <= 0.0 || b.height() <= 0.0 {
continue;
}
@@ -877,7 +918,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
show_center_mark: false,
show_cross_hairs: false,
});
converted.push(*id);
converted.push(id);
}
if converted.is_empty() {
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
@@ -907,8 +948,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
};
let count = s.doc()?.doc.artboards.len();
let n_ab = count.max(1);
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
// Columns of a grid by row, rows of a grid by column.
let lines = whole_or(p, "columns", 2, CMD)?.clamp(1, n_ab as u64) as usize;
let (rows, cols) = match layout {
L::GridByRow => (n_ab.div_ceil(lines), lines),
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
@@ -929,8 +970,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
}
// With moveArtwork, each top-level object moves with the artboard its center is on; locked and
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
// hidden art moves only when prefs moveLockedWithArtboard is on.
let symbols = d.symbol_boxes();
let art: Vec<(NodeId, Vec2)> = if move_art {
d.layers
.iter()
@@ -938,8 +981,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.rides_with_artboard(locked_and_hidden))
.filter_map(|n| {
let c = n.geometric_bounds()?.center();
let dl = *rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))?;
let c = n.geometric_bounds_with(&symbols)?.center();
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
(dl != Vec2::ZERO).then_some((n.id, dl))
})
.collect()
+2
View File
@@ -34,6 +34,7 @@ mod halftone;
pub mod help;
pub mod importmatches;
pub(crate) mod inline;
mod interactivity;
mod intertwine;
mod layer;
mod layerpanel;
@@ -263,6 +264,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(library::specs());
v.extend(expand::specs());
v.extend(attributes::specs());
v.extend(interactivity::specs());
v.extend(newart::specs());
v.extend(colortheme::specs());
v.extend(fileio::pdf::specs());
Loaded 100 of 477 files, more files were not shown because too many files have changed in this diff. Show more