Compare commits

...
Author SHA1 Message Date
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
bflatastic a64a5a9911 UI: Classic colour scheme (User Interface › Color Scheme)
A second colour scheme next to Modern: the neutral greys and blue accent of
earlier versions, on the same cards, corners and layout. Picked in
Preferences › User Interface › Color Scheme (pref uiColorScheme:
modern|classic); Accent Color is dimmed while Classic is chosen, which has
its own blue.
2026-10-16 14:29:33 +07:00
skylar taylor-barrick ae6dc17ffe Merge pull request #1184 from dylanmtaylor/refactor/dry-github-actions
ci: refactor workflows to use storytold/craft-common-actions and add nix check
2026-10-15 21:46:35 -06:00
skylartaylor 2fa10c957c ci: retain VectorCraft package and release checks 2026-10-16 05:43:40 +02:00
BFlat deff5b8f42 Merge pull request #2339 from storytold/readme/screenshots
README: new screenshots, with 3D Revolve and Dimension tool showcases
2026-10-16 09:57:55 +07:00
bflatastic c334aa7e66 README: new screenshots of the current interface, with 3D Revolve and Dimension tool showcases
Seven app screenshots retaken at 2240x1400 (UI at 1.6x), two of them new: a 3D still life with the Revolve dialog (examples/still-life.vectorcraft) and a bracket drawing with linked dimensions (examples/bracket-drawing.vectorcraft). The still life also joins Made in VectorCraft. The older examples are saved again in the current native format; they render the same.
2026-10-16 09:53:12 +07:00
BFlat 3f33e854d4 Merge pull request #2331 from storytold/ui/restyle
UI: restyled interface
2026-10-16 09:11:04 +07:00
bflatastic 257217a2e3 UI: restyled interface with a choice of accent colours 2026-10-16 09:00:14 +07:00
Dylan M. Taylor f08f3619e3 ci: refactor workflows to use storytold/craft-common-actions and add nix check 2026-10-15 21:38:50 -04:00
BFlat 8770d2a96c Merge pull request #1902 from Messhias/feat/g12-layers-collect-export
G12: Collect for Export on Layers (≡ and row menu)
2026-10-16 06:37:53 +07:00
BFlat c64c0120f5 Merge pull request #1918 from sanyamsingh001/codex/r65-guide-origin
G1: align guide snapping and readouts with ruler origin
2026-10-16 06:37:36 +07:00
BFlat 40fe3154ee Merge pull request #1912 from p0lyg0n/i18n/ja-swatches
i18n: the Swatches panel shows the default swatches' names in Japanese
2026-10-16 06:37:17 +07:00
BFlat a812bb7dcc Merge pull request #1900 from imachus/pr/ja-whitespace-fallback
Type: whitespace the primary font doesn't map takes the fallback face too (#1844)
2026-10-16 06:36:58 +07:00
BFlat 756ec5c5e3 Merge pull request #2281 from limackk/fix/round-corners-curve-line
Effects: Round Corners also rounds corners where a curve meets a line
2026-10-16 06:36:40 +07:00
BFlat 0bd4ce2260 Merge pull request #2280 from guillehr2/fix/transform-copies-symbol-instances-and-reach
effects: a Transform effect copies a group's symbol instances too, and stops image copies at MAX_REACH
2026-10-16 06:36:13 +07:00
BFlat 06fdeeef6c Merge pull request #2283 from marcoarnulfo/fix/svg-mask-type-alpha
SVG import: an alpha mask counts by its art's alpha, not its luminance
2026-10-16 06:35:54 +07:00
BFlat 9da787f951 Merge pull request #1888 from dotCooCoo/fix/brush-art-own-stroke-outlines
Render: the strokes of brush art are no longer drawn with old outlines
2026-10-16 06:35:36 +07:00
BFlat 2853d78e66 Merge pull request #1889 from dotCooCoo/fix/commands-with-nested-ids
Commands: ids leave out an object listed with a group that holds it, as the selection does
2026-10-16 06:35:21 +07:00
BFlat 3352e02a2b Merge pull request #2301 from dotCooCoo/feat/glass-load-texture
Effects: Glass loads its texture from an image file
2026-10-16 06:35:05 +07:00
BFlat 915a172b9a Merge pull request #2288 from dotCooCoo/feat/unit-fields-percent-arithmetic
Fields: unit fields take % in arithmetic, as in 3cm*50% and 50pt+25%
2026-10-16 06:34:49 +07:00
BFlat 59c872baaa Merge pull request #2290 from dotCooCoo/fix/transform-effect-copies-stay-type
Export: type copied by a Transform effect that only moves it stays type
2026-10-16 06:34:26 +07:00
BFlat 89a7992c98 Merge pull request #2289 from dotCooCoo/test/pdf-fuzz-unparseable-resources
Tests: the PDF import fuzz covers values the reader can't parse and functions inside other objects
2026-10-16 06:34:09 +07:00
BFlat 15819868a2 Merge pull request #2291 from dotCooCoo/fix/eps-cmyk-images-keep-samples
EPS import: CMYK images keep their ink amounts
2026-10-16 06:33:53 +07:00
BFlat e41f7818d6 Merge pull request #2293 from dotCooCoo/fix/ai-one-bit-color-images
.ai import: 1-bit RGB and CMYK images in the editing data open in their colors
2026-10-16 06:33:36 +07:00
BFlat 7316cf7e39 Merge pull request #2298 from dotCooCoo/fix/windows-start-after-graphics-driver-crash
Windows: after a graphics driver crash, the next start leaves that adapter out
2026-10-16 06:33:20 +07:00
BFlat 114960482e Merge pull request #2299 from dotCooCoo/feat/pdf-ai-file-info
PDF/.ai import: PDF and .ai files open with the File Info they carry
2026-10-16 06:33:04 +07:00
BFlat 23cfe935db Merge pull request #2303 from dotCooCoo/fix/removed-fonts-leave-the-list
Fonts: a removed font that type used leaves the list at the next font scan
2026-10-16 06:32:41 +07:00
Fabio William Conceição b04e3ac366 G12: Collect for Export from Layers panel and layer ids
Layers ≡ and row menus collect art via assets.add; a layer id expands to the art under it (including sublayers) so MCP and the panel match Object › Collect for Export.
2026-10-16 06:16:36 +07:00
sanyamsingh001andCodex 60c8b8f4e2 G1: align guide snapping and readouts with ruler origin
Co-authored-by: Codex <[email protected]>
2026-10-16 06:16:36 +07:00
Hiroshi InukaiandClaude Opus 5.5 75ee324e6c i18n: the Swatches panel shows the default swatches' names in Japanese
The list showed [None], [Registration] and the default swatches' names in
English. Names the app gives (the default swatches and colour groups) now
show in the UI's language where it has a `swatch` row for them; documents
keep the names as they are, and a name the user gave shows as written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
imachusandClaude Opus 5.5 4265c2f301 Type: whitespace the primary font doesn't map takes the fallback face too (#1844)
An ideographic space in a face without U+3000 was shaped with that face
and could set at zero width, removing an indent. Only what the layout
handles itself (tab, line and paragraph breaks) stays with the primary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
Kamil KwiatkowskiandClaude Opus 5.5 a14942de9c Effects: Round Corners also rounds corners where a curve meets a line
A corner whose anchor carries a handle (where a curve meets a line, or two
curves meet) was left sharp. It now gets Live Corners' round cut
(geom::corners, #1144), reaching `radius` along each side and trimming a
curved side. Corners between handle-less anchors are rounded exactly as
before, and a closed path still starts where its first corner's rounding
starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
Guillem Hermida d05ccffebc effects: a Transform effect copies a group's symbol instances too, and stops image copies at MAX_REACH
Symbol instances are placed through a transform as images are, so the
Transform effect now moves and copies both (transform_placed), sharing one
copy budget (MAX_COPY_OBJECTS). Copies of either stop before one reaches
past MAX_REACH, as a path's do (distort::within_reach).

Adds end-to-end export tests for a group's image copies in svg and pdf.
2026-10-16 06:16:35 +07:00
Marco Arnulfo 789a151ff9 SVG import: an alpha mask counts by its art's alpha, not its luminance (fixes #2210) 2026-10-16 06:16:35 +07:00
dotCooCoo a8e612d3e2 Render: the strokes of brush art are no longer drawn with old outlines
While the renderer drew a brushed path on its fast path, it drew the
pieces of brush art with that path still set as the object being
drawn. cached_stroke and fill_path_gradient then kept the outlines and
gradient slices of the pieces' strokes under the brushed path and the
address of each piece's stroke. The entries held the brushed path and
not the pieces, so a piece made later could take the address of an
earlier piece's stroke and be drawn with that piece's outline. The
instances of a symbol and the copies of a live repeat replace each
other's brush art, so it is made again for each of them in every
frame, and every brushed path gets new brush art when the document's
brushes change. In a test, a renderer drawing three instances of a
symbol whose art holds a path brushed with a scatter brush of a
stroked square drew frames that differed from a new renderer's in 316
to 1,140 pixels.

The two caches now keep only the strokes of the object being drawn. With
vectorcraft-cli bench at 2880x1800 on 8 threads, 2,000 instances of a
symbol of eight brushed paths took 203.4 ms a frame on main and 155.9 ms
with this change. 2,000 groups of the same paths, for which main keeps
the brush art and the outlines of its strokes between frames, took 133.7
ms on main and 156.9 ms with this change. The peak working set of a
bench run at 2880x1800 went from 609 MiB to 127 MiB on the instances and
from 331 MiB to 210 MiB on the groups.
2026-10-16 06:16:35 +07:00
dotCooCoo 67a278b053 Commands: ids leave out an object listed with a group that holds it, as the selection does
Commands that read the selection through edit::selected_roots leave
out an object whose group is also selected (edit::roots_of). Fourteen
places that read `ids` kept an object listed together with its group.
Given ids that list an object and a group that holds it, Make Repeat
failed with "no such object", New Symbol failed or stored the object
twice in the symbol, and object.transform moved it twice (20 pt for a
10 pt move).

These places now pass explicit ids through edit::outermost, which
drops an id whose ancestor is also listed, with the filter that
roots_of runs on the selection (now edit::without_inner). Nothing else
is dropped or replaced: unlike roots_of, it keeps layers and
compound-path members. A list without nesting gives the same result as
before. Commands that take both ids and the selection as listed, such
as paint.setFill without item, are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
bflatastic a6f56caa0e Glass Load Texture: el, fi, pt-br and sv rows 2026-10-16 06:16:35 +07:00
dotCooCoo 7e9d0be723 Fields: unit fields take % in arithmetic, as in 3cm*50% and 50pt+25%
Unit fields and length strings read a percentage operand as a share of
a value. After * or /, the percentage scales the term before it:
3cm*50% is 1.5 cm and 3cm/50% is 6 cm. After + or -, it adds or takes
away that share of everything before it: 50pt+25% is 62.5 pt and
100pt+20pt-50% is 60 pt. Before this change these expressions read as
nothing, and the field kept its old value.

The parser now splits a sum at its last + or -, so a percentage applies
to the whole sum before it. Text without % gives the same values as
before, apart from float rounding. A percentage on its own (50%) still
reads as nothing, and percent and degree fields still read % as their
suffix.

Command length strings use the same parser: file.new's width and
height, the distance params and the length preferences.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo da43c662e0 Export: type copied by a Transform effect that only moves it stays type
Exports bake live effects into plain art, and a Transform effect on
type or on its group or layer turned the type into glyph outlines, even
when the effect only moved it or made moved copies. A PDF reader could
not select or search that text.

The export bake now keeps such type as type, moved into each place the
effect puts its outlines. It does this for Transform effects without
scale, rotation, reflection or Random, for type whose characters are in
solid colors without underline or strikethrough, and that has no fills
or strokes of its own, inline graphics or perspective, and when the
copies do not overlap. Type under such an effect on its group or layer
also needs no transparency or effects of its own, and the group or
layer needs no fill, stroke or Pathfinder effect of its own. The export
still outlines other type under geometry effects, and the canvas and
Expand Appearance still draw outlines.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo 146c43267a Tests: the PDF import fuzz covers values the reader can't parse and functions inside other objects
A new import fuzz test, pdf_resources_the_reader_cannot_parse_never_panic,
imports a page whose resources hold:

- a value the reader can't parse (/#zz, 12-.5) or one it reads (/Fine)
  in the color space dictionary, before a Separation space the page
  paints in, and in the font dictionary, before the font of its text;
- a calculator function written as the shading's function, or inside an
  array or a dictionary in the page's /Properties: a plain one, one with
  Columns 0, one with a huge Columns, or one of 257 MiB of deflated
  spaces.

The import must not panic. The file opens with its art, or it is refused
with "would decode to more than 256 MB" when the function is not plain.

With the nesting check, the font scan or the ink scan reading
Dict::entries again, the test fails on a value the reader can't parse.
With the stream check made on the file's objects alone, the test fails
on every function that is not plain: the file opens instead of being
refused.

The calculator helper of pdf_streams_the_reader_would_fail_on_are_refused
is now a function of the file, and the function of spaces is made once.
2026-10-16 06:16:35 +07:00
dotCooCoo 3b327d9512 EPS import: CMYK images keep their ink amounts
The PostScript interpreter that reads EPS and PostScript files turned
the samples of a CMYK image into display RGB and stored an RGBA PNG,
so the image lost its ink amounts. The DCTDecode filter decoded a CMYK
JPEG to RGB, so it lost its ink amounts too. The PDF importer keeps
the ink amounts of 8- and 16-bit CMYK images (in a CMYK TIFF, or the
CMYK JPEG itself).

A CMYK image (image or colorimage in DeviceCMYK, CIEBasedDEFG or a
four-component ICCBased space) now keeps its samples in a CMYK TIFF
(ImageBlob::cmyk_tiff), read through its Decode array, at every bit
depth the interpreter reads. A CMYK JPEG gives its samples as stored,
and the image's Decode array reads them: [1 0 1 0 1 0 1 0] undoes the
inverted samples of Adobe's CMYK JPEGs. The pixels that a MaskColor
or an ImageType 3 mask hides become a grayscale opacity mask over the
image, as in PDF import. A mask that hides nothing adds no mask. Gray,
RGB, indexed and spot color images and image masks stay RGBA PNGs.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo 28cb75cc59 .ai import: 1-bit RGB and CMYK images in the editing data open in their colors
The editing data reader sized each row of a 1-bit image as width ×
channels bits, padded to a whole byte, but read one bit per pixel and
made the pixel gray from it. A 1-bit RGB or CMYK image came out as a
black and white image of the wrong bits. When that made the layers
differ from the page by more than 5%, the file opened from its PDF
part instead, with a warning.

The reader now takes channel c of pixel x from bit x × channels + c of
the row, a set bit being the channel at full strength, and converts
the samples as it converts 8-bit ones. 1-bit gray images read as
before.
2026-10-16 06:16:35 +07:00
bflatastic 50111c87fd Graphics start record: el, fi and sv rows 2026-10-16 06:16:35 +07:00
Kamil KwiatkowskiandClaude Opus 5.5 716a968e7a Contributors: add limackk (Kamil Kwiatkowski)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
dotCooCoo c586cecf75 Effects: Glass loads its texture from an image file
Effect › Distort › Glass… offered only its four built-in textures. The
reference app's Texture menu also has Load Texture, which takes an
image file as the glass surface.

The Glass dialog now has a Load Texture… button. It reads a PNG, JPEG,
GIF, WebP, TIFF, BMP, PSD or PSB file within the pixel allowance the
renderer draws images in, and the Texture menu gains Loaded Texture.
The effect stores the image's gray levels in `textureImage` as a
grayscale PNG in base64, so the document saves the texture with the
effect, and copies of the effect carry it. effect.apply and
effect.setParams take `textureImage` as {path}, {name, dataBase64} or
the stored string; any other value is an error.

The texture lies one image pixel per point at Scaling 100%, centered on
the object and repeated. Each pixel's luma is the surface's height,
from 0 for black to 1 pt for white, and a transparent pixel counts as
middle gray. A Glass without `textureImage` draws and saves as before.

Docs: docs/mcp.md (Glass textures), docs/effects-parity.md,
docs/gaps.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo 261b64251d PDF/.ai import: PDF and .ai files open with the File Info they carry
A PDF, PDF-compatible .ai or .ait opened from its pages had an empty
File Info. Nothing read the file's document information
dictionary or its XMP packet, so file.info reported no author,
description or keywords, even for a PDF that VectorCraft exported.

vectorcraft_pdf::file_info reads both. The author, description,
keywords, copyright status, notice and URL and the created and modified
dates come from the XMP packet (dc:creator, dc:description, dc:subject
or pdf:Keywords, xmpRights:Marked, dc:rights, xmpRights:WebStatement,
xmp:CreateDate, xmp:ModifyDate), else from the dictionary's Author,
Subject, Keywords, CreationDate and ModDate. document.open,
file.newFromTemplate and file.revert put each value through file.info's
checks and leave out a value that file.info refuses. A packet that is
not well-formed XML leaves the dictionary's values. File Info does not
set the title, and a file restored from the document it carries keeps
that document's File Info. vectorcraft-pdf depends on roxmltree 0.21,
which usvg already brings into Cargo.lock.

Docs: docs/mcp.md (File Info), docs/file-format-parity.md,
docs/target-app-parity.md.
2026-10-16 06:16:34 +07:00
dotCooCoo d1089270ba Fonts: a removed font that type used leaves the list at the next font scan
When type had used a font, removing the font while the app ran left
it in the font menus, and the type kept drawing in it until the app
restarted. The font list is the loaded faces plus the scanned catalog,
and a scan replaced the catalog but kept every loaded face.

A scan now drops each loaded face whose file it did not catalog, with
the face's cached outlines. A family with no face left leaves the font
lists, and type set in it lays out in the fallback font and is
reported missing, as when a document opens without the font. When the
font is installed again, the next scan lists it and the type draws in
it again. Bundled fonts and fonts added as bytes have no file and
stay.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 06:16:34 +07:00
dotCooCoo ee5b0938aa Windows: after a graphics driver crash, the next start leaves that adapter out
When a graphics driver crashed the process while the window started,
as Intel's Direct3D 12 driver (igd12umd64.dll) did on an Intel UHD
Graphics 605 (#2243), the next start picked the same adapter again. The
restart without a failing adapter and the first-frame watch run inside
the process, so the crash ended them along with it.

Each start now writes graphics-start.json in the log folder before the
graphics start, adds the adapter once one is picked, and clears both
when the first frame reaches the screen or the app ends without
crashing. A start that finds an earlier start still recorded there
leaves that adapter out, as a restart does, logs it and shows a note in
the status bar. When the crash came while the adapters were being
listed (wgpu's Direct3D 12 backend creates a device on each GPU to list
it), the next starts list OpenGL's adapters alone, except on macOS. The
adapters stay left out until the app version or the Graphics Processor
preference changes, a start shows its first frame on one, or no adapter
is left. WGPU_BACKEND and WGPU_ADAPTER_NAME choose regardless of the
record.

Docs: docs/development.md, docs/target-app-parity.md.
2026-10-16 06:16:34 +07:00
BFlat 76d5ca8bfa Merge pull request #2304 from storytold/docs/2026-10-16-landings
Docs: the landings of 2026-10-15/16 in the ROADMAP log, and the new languages
2026-10-16 05:59:53 +07:00
bflatastic 080d90ed7f Docs: the landings of 2026-10-15/16 in the ROADMAP log, languages in the ROADMAP, README and localization parity 2026-10-16 05:56:27 +07:00
BFlat 68887a21c5 Merge pull request #1994 from Janiz-zzz/finnish-interface
Add Finnish interface language
2026-10-16 05:53:29 +07:00
BFlat 50152c9941 Merge pull request #2076 from Camthalion77/feature/greek-localization
Add Greek (el) localization
2026-10-16 05:53:10 +07:00
BFlat f29390c590 Merge pull request #2139 from kler/swedish-ui
i18n: Swedish (sv) interface translation
2026-10-16 05:52:52 +07:00
BFlat 872e86d3f6 Merge pull request #2002 from tastyhub/flatpak-font-manager-fonts
Flatpak: fonts a font manager activates into ~/.fonts are visible
2026-10-16 05:52:34 +07:00
BFlat d6e4366885 Merge pull request #1999 from tydude001/rpm-glibc-floor-libm
packaging: RPM glibc floor names libm's GLIBC_2.35, so RHEL 9 refuses it
2026-10-16 05:52:18 +07:00
bflatastic 094393959d i18n (fi): rows for the strings main added since the catalog was written
English keeps its own name, as the Language menu shows every language in its own name.
2026-10-16 05:44:50 +07:00
bflatastic ea164c1543 i18n (el): a complete catalog, with rows for the strings main added since it was written
English keeps its own name, as the Language menu shows every language in its own name.
2026-10-16 05:44:50 +07:00
Janiz-zzzandClaude Opus 5.5 280de29c86 Add Finnish interface language
Adds a Finnish (Suomi, `fi`) catalog with 4,770 entries, written in
sentence case from the English labels with ordinary Finnish
vector-illustration vocabulary. It covers menus, `tl!` literals and the
668 `@msg` status and error messages: `fi` is in KEEPS_MENU_NAMES and
COMPLETE_MESSAGES. Like the other complete catalogs, it leaves the
product, workspace and perspective preset names in English.

Plurals are one/other with the partitive singular after any number but
1 (1 taso, 2 tasoa). Any fi-* locale selects Finnish; sv-FI does not.

Tests: `cargo test -p vectorcraft-ui-egui --lib i18n` passes (47),
including the new finnish_reads_as_finnish and the bundled-font glyph
check.

Clean-room, AI-assisted (Claude Code) translation; a native speaker
checked it in the running app, but it has not been proofread line by
line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:44:50 +07:00
bflatastic b68c467b45 i18n (sv): rows for the strings main added since the catalog was written 2026-10-16 05:37:33 +07:00
Γιώργος Αθενάκης afe0239600 Add Greek (el) localization 2026-10-16 05:37:33 +07:00
Patrik LermonandClaude Opus 5.5 a91129e422 Swedish interface translation (sv)
Adds Swedish (Svenska) as an interface language: 4770 strings, written from the English source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:27:45 +07:00
Ryan WalkerandClaude Opus 5.5 ced45ec157 Flatpak: fonts a font manager activates into ~/.fonts are visible
Flatpak opens only /usr/share/fonts and ~/.local/share/fonts to a sandbox, so the
fonts a font manager such as FontBase activates into ~/.fonts were missing from the
font list while native apps showed them. Granted read-only: VectorCraft only reads
fonts.

Verified on the 0.11.0 release bundle by applying the same grant as a user override
(`flatpak override --user --filesystem=~/.fonts:ro`): in a fresh sandbox the FontBase
families went from none to all 167, `fc-match` resolved a family that had been missing
from the app, and the font then appeared in the font list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:26:32 +07:00
tydude001andClaude Opus 5.5 e12a670c40 packaging: RPM glibc floor names libm's GLIBC_2.35, so RHEL 9 refuses it
#1290 (from #1275) declared the glibc floor as `libc.so.6(GLIBC_2.35)(64bit)`. That doesn't stop
RHEL 9: its current glibc, `2.34-283.el9_8`, provides `libc.so.6(GLIBC_2.35)` through backports. On
Rocky Linux 9, dnf upgrades glibc to that build, installs VectorCraft 0.10.0, and `vectorcraft-cli`
then fails with ``/lib64/libm.so.6: version `GLIBC_2.35' not found``. The binaries' only GLIBC_2.35
symbols are `hypot` and `hypotf`, from libm, and RHEL 9 doesn't provide `libm.so.6(GLIBC_2.35)`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:26:32 +07:00
BFlat 82db3344bf Merge pull request #1928 from Messhias/feat/g16-pt-br-complete
G16: complete Brazilian Portuguese (pt-br) interface
2026-10-16 05:13:53 +07:00
BFlat 96ce65954e Merge pull request #1943 from Messhias/feat/g6-mcp-dataset-docs
G6: document dataset commands and command.batch in MCP guide
2026-10-16 05:13:38 +07:00
BFlat 80178d9902 Merge pull request #1938 from Messhias/feat/g6-actions-batch
G6: action.batch (File › Automate › Batch)
2026-10-16 05:13:23 +07:00
BFlat 28fe30ce58 Merge pull request #1941 from Messhias/feat/g6-graph-variables
G6: graph variables (CSV data merge)
2026-10-16 05:13:07 +07:00
BFlat 5f7f9f6d37 Merge pull request #1929 from Messhias/feat/g6-dataset-csv
G6: dataset CSV import/export
2026-10-16 05:12:50 +07:00
BFlat 1060bc6bed Merge pull request #1908 from Messhias/feat/g6-image-variables
G6: image variables (VariableKind::Image)
2026-10-16 05:12:35 +07:00
BFlat 1ea496a04f Merge pull request #1959 from Messhias/feat/symbol-registration
G5: Symbol Options Registration 9-point grid
2026-10-16 05:12:19 +07:00
BFlat a302783f32 Merge pull request #2053 from sanyamsingh001/codex/r76-warp-options
G1: Add Warp Options controls for live effects
2026-10-16 05:12:08 +07:00
BFlat bb66ec13e2 Merge pull request #1961 from Messhias/feat/assets-export-seam-test
G11: Asset Export seam regression and supersampled thumbs
2026-10-16 05:11:50 +07:00
BFlat 9ab58ba536 Merge pull request #1922 from sanyamsingh001/codex/r68-guide-escape
G1: Cancel existing-guide drags with Escape
2026-10-16 05:11:32 +07:00
BFlat 756d05d7ca Merge pull request #1982 from sanyamsingh001/codex/r76-open-anchor-delete
G11: Preserve gaps when deleting open-path anchors
2026-10-16 05:11:16 +07:00
BFlat 6dc60a6697 Merge pull request #1921 from sanyamsingh001/codex/r65-artboard-document-switch
G1: Keep the displayed artboard selected after switching documents
2026-10-16 05:11:01 +07:00
BFlat 5d3d28d56f Merge pull request #2095 from switch87/feat/1763-intertwine
G13: Object › Intertwine: Make, Edit, Release and the Intertwine tool (#1763)
2026-10-16 05:10:43 +07:00
BFlat 832ca236d3 Merge pull request #1936 from marcoarnulfo/fix/macos-title-bar-double-click
Title bar: a double-click on the macOS app bar zooms the window
2026-10-16 05:10:27 +07:00
BFlat 82d5e31be4 Merge pull request #2125 from switch87/feat/2113-batch-draw
MCP command_batch: draw_path and draw_shape run as steps (#2113)
2026-10-16 05:10:12 +07:00
BFlat 3fb463052e Merge pull request #2124 from switch87/feat/2112-draw-options
MCP draw_path / draw_shape: dash, cap, join, opacity, name and effects (#2112)
2026-10-16 05:09:57 +07:00
BFlat 65c06f3a57 Merge pull request #2128 from switch87/feat/2127-gradient-span
paint.setFill / setStroke: one gradient across the targets (span: "selection") (#2127)
2026-10-16 05:09:42 +07:00
BFlat 93e92d8a91 Merge pull request #2137 from switch87/feat/2130-ids-select-first
Selection-only commands take ids: the objects are selected first (#2130)
2026-10-16 05:09:27 +07:00
BFlat 5e2dd693ae Merge pull request #1962 from marcoarnulfo/fix/pdfx-icc-once
PDF export: the output intent shares the stream of the profile that tags the colours
2026-10-16 05:09:12 +07:00
BFlat dcb759011e Merge pull request #2121 from switch87/fix/2110-gui-help
vectorcraft --help prints the usage before the app starts (#2110)
2026-10-16 05:08:56 +07:00
BFlat 13a1b60d28 Merge pull request #1947 from marcoarnulfo/fix/text-center-shortcut-and-autosize
Text: Ctrl+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size
2026-10-16 05:08:41 +07:00
BFlat 2c1bb5f839 Merge pull request #1981 from marcoarnulfo/fix/maximize-work-area
Windows: a maximized window stays clear of the taskbar
2026-10-16 05:08:26 +07:00
bflatastic f7ab3277e0 Docs: one row per area and milestone after the stacked doc edits (#1928) 2026-10-16 04:55:02 +07:00
bflatastic 93d6411cce i18n: Brazilian Portuguese rows for the messages added since (#1928) 2026-10-16 04:55:02 +07:00
Fabio William ConceiçãoandCursor 4614821372 docs: note pt-br completion in target-app-parity status
Co-authored-by: Cursor <[email protected]>
2026-10-16 04:55:02 +07:00
Fabio William Conceição 671136fa66 G16: complete Brazilian Portuguese (pt-br) interface
Finish the partial pt-br catalog so menus, tl! literals and status/error
messages stay translated under the completeness tests (#1914).
2026-10-16 04:55:02 +07:00
bflatastic 2ce3411493 Docs: one Variables and one Data sets entry in the MCP guide (#1943) 2026-10-16 04:55:01 +07:00
bflatastic 8412b4658f i18n: catalog rows for the new strings (#1938) 2026-10-16 04:55:01 +07:00
Fabio William ConceiçãoandCursor 7ca2763587 G6: document dataset commands and command.batch in MCP guide
Spell out dataset.list/capture/update/delete/rename and distinguish the MCP
command_batch tool from the engine command.batch Actions play.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:55:01 +07:00
bflatastic 08dde8783e Actions: Batch… stays in the Actions panel menu, as the reference app has it (#1938) 2026-10-16 04:55:01 +07:00
bflatastic b3e484702f i18n: catalog rows for the new strings (#1941) 2026-10-16 04:50:07 +07:00
bflatastic 799f94ecf1 i18n: catalog rows for the new strings (#1929) 2026-10-16 04:50:07 +07:00
bflatastic a38c824066 Variables: an image variable keeps the object's bounds with the Placement Options API on main (#1908) 2026-10-16 04:50:07 +07:00
Fabio William ConceiçãoandCursor 0fe70314b8 G6: action.batch for File › Automate › Batch
Play an action on every file in a folder or every data set, with destination
and Override Open/Save/Export. Actions panel and File › Automate › Batch…
open the dialog; new strings in pt-br.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:50:07 +07:00
bflatastic 568008dd47 Variables: graph values go through the dataset CSV too (#1941) 2026-10-16 04:50:07 +07:00
bflatastic 192a671d4f Variables: dataset CSV keeps line breaks in quoted cells and reads a file only up to its cap (#1929) 2026-10-16 04:50:07 +07:00
Fabio William ConceiçãoandCursor dc390aba37 G6: graph variables (CSV data merge)
VariableKind::Graph binds a graph root; dataset rows supply CSV as graph.setData.
Make Graph Dynamic, Variables dialog, capture/apply, and pt-br for the new strings.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:50:07 +07:00
Fabio William Conceição 5ef090a0c2 G6: dataset CSV import/export with PT-BR UI labels
Load and save variable datasets as CSV for data-merge pipelines, and
translate the new Variables panel commands in Brazilian Portuguese.

Co-authored-by: Cursor <[email protected]>

# Conflicts:
#	docs/target-app-parity.md
2026-10-16 04:50:07 +07:00
bflatastic 699856f7d5 i18n: catalog rows for the new strings (#1908) 2026-10-16 04:45:08 +07:00
Fabio William Conceição 41c8bd2a2d G6: image variables swap linked files and keep bounds
Add VariableKind::Image with path values on datasets, makeImageDynamic,
panel/dialog UI, and MCP docs so data merge can drive placed images.
2026-10-16 04:45:06 +07:00
bflatastic c8310edea1 i18n: Bulgarian row for Symbol Options Registration (#1959) 2026-10-16 04:45:04 +07:00
sanyamsingh001andCodex 848d902354 G1: Add Warp Options controls for live effects
Expose existing Warp styles, orientation and percentage controls through the shared live effect dialog while preserving preview history and effect targets. Keep ordinary Enter owned by focused non-searchable combo controls.

Co-authored-by: Codex <[email protected]>
2026-10-16 04:45:03 +07:00
Fabio William ConceiçãoandCursor 80090e7623 G5: Symbol Options Registration 9-point grid
Store the registration point with the symbol and place instances so that point lands on (x, y). Symbol Options exposes the same 9-point control as Transform.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:45:03 +07:00
Fabio William Conceição ed6d474042 G11: Asset Export seam regression and supersampled thumbs
assets.export of abutting fills must leave no ghost edge (#983 / #1037); panel thumbnails use the same Art Optimized supersample. Progress docs mark the gap closed.
2026-10-16 04:45:01 +07:00
sanyamsingh001andCodex 74573d6bad G1: Cancel existing-guide drags with Escape
Co-authored-by: Codex <[email protected]>
2026-10-16 04:44:59 +07:00
sanyamsingh001andCodex 67f9d134ed G11: Preserve gaps when deleting open-path anchors
Keep surviving drawable runs separate when Delete or Backspace removes an open-path anchor. Preserve appearance, selection and one-step Undo.

Co-authored-by: Codex <[email protected]>
2026-10-16 04:44:58 +07:00
sanyamsingh001andCodex a1b3c4804f G1: Keep the displayed artboard selected after switching documents
Co-authored-by: Codex <[email protected]>
2026-10-16 04:44:56 +07:00
bflatastic 9eddeeb952 Legacy .ai export writes Intertwine groups as their art (#2095) 2026-10-16 04:44:54 +07:00
bflatastic 5e75d26d29 i18n: Bulgarian rows for Intertwine (#2095) 2026-10-16 04:44:54 +07:00
Gert Pellin 3ae6cf28e4 G13: Intertwine tool, Object › Intertwine › Edit, menu and translations (#1763)
Make and Edit choose the Intertwine tool: hovering highlights the overlap
area under the pointer, a click swaps which object is on top there, and a
path drawn around overlaps (Shift: a rectangle) arranges every overlap
inside it; Esc or Enter goes back to the tool chosen before. The overlap
faces move to vectorcraft-tools, shared by the tool and the command.

Clicks inside a raised area pick the member drawn on top there, the
Object menu gets the Intertwine submenu, and the new labels and the
predicate message are translated in the complete catalogs. docs/gaps.md
records what landed under G13.
2026-10-16 04:44:53 +07:00
Marco Arnulfo 20325b1a89 Title bar: a double-click on the macOS app bar zooms the window (fixes #1932) 2026-10-16 04:44:52 +07:00
Gert Pellin ba254a7481 MCP command_batch: draw_path and draw_shape run as steps (#2113)
A step whose id is draw_path or draw_shape runs that tool with its params,
checked as tools/call checks arguments; its result is the tool's, so later
steps can use "$N.id". The reply lists the ids the drawing steps made
under created. The unknown-argument check is shared with tools/call.
2026-10-16 04:44:52 +07:00
Gert Pellin 49fcfbd145 MCP draw_path / draw_shape: dash, cap, join, opacity, name and effects (#2112)
The drawing tools style the object they draw in the same call: dash, cap
and join go to the stroke.set that strokeWidth already sends, opacity and
name to object.setProps, and effects are applied in order as apply_effect
does. The options are checked (effect ids against effect.list) before
anything is drawn, so a bad one leaves no half-styled object behind.
2026-10-16 04:44:52 +07:00
bflatastic 5fc7072600 i18n: catalog rows for the new strings (#2128) 2026-10-16 04:44:52 +07:00
Gert Pellin 74c391c7ba G13: Object › Intertwine: Make, Release and arranging overlap areas (#1763)
An Intertwine group keeps its objects as they are, in their own stacking
order; each member can be drawn over the others in an area of its own.
The group draws, hits, saves and exports through its live expansion, so the
members stay editable and Release gives them back unchanged.

object.intertwine.arrange picks an overlap area by a click point or by a
closed path drawn around overlaps and toggles which member is on top
there, or brings a member to the front or sends it to the back where three
or more objects overlap.
2026-10-16 04:44:52 +07:00
Gert Pellin e7aacec852 Selection-only commands take ids: the objects are selected first (#2130)
object.group, ungroup, the clipping mask and compound path Make/Release,
the Arrange commands, lock, hide, edit.duplicate, object.move, rotate,
scale, reflect, shear and the Pathfinder commands acted only on the
selection, so a script had to send select.set first. Given ids, they now
select those objects (select.set, with its checks) and run as on the
selection, in the same undo step; without ids nothing changes.
2026-10-16 04:44:50 +07:00
Gert Pellin 736abd811d paint.setFill / setStroke: span "selection" places one gradient across the targets (#2127)
A gradient given to several objects fits each one's own bounds. With
span: "selection", a linear or radial gradient without its own vector is
placed once on the union of the targets' bounds, at its angle and aspect,
and every target gets that vector in document coordinates, so the ramp
runs on across them. A solid colour, a swatch, a freeform gradient or an
explicit start/end with it is refused before anything changes; the next
object drawn still fits the gradient to itself.
2026-10-16 04:44:50 +07:00
Gert Pellin 265552d5f4 Tests: a gradient spanning the selection (#2127) 2026-10-16 04:44:50 +07:00
bflatastic e6b28f0e80 Tests: read the PDF/X output intent's profile with the crate's own xref reader (#1962) 2026-10-16 04:44:31 +07:00
Gert Pellin e2df2e774c vectorcraft --help prints the usage before the app starts (#2110)
--help and -h used to be taken for a file to open: from a terminal, SSH or an
agent's shell the window opened, or the start hung or failed without a display.
They now print the desktop app's options and point to vectorcraft-cli for
headless work, exiting before logging, preferences or the window, as
--version does.
2026-10-16 04:44:31 +07:00
bflatastic 2843687ef4 Text: leave out the bottom-handle Auto Size double-click until its behaviour is settled (#1947) 2026-10-16 04:44:31 +07:00
Marco Arnulfo 32dbc4b641 PDF export: the output intent shares the stream of the profile that tags the colours (fixes #1954) 2026-10-16 04:44:31 +07:00
Marco Arnulfo a69371656b Text: Ctrl+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size (fixes #1940) 2026-10-16 04:44:29 +07:00
Marco Arnulfo 4b8a987445 Windows: a maximized window stays clear of the taskbar (fixes #1977) 2026-10-16 04:44:11 +07:00
BFlat 52b072873d Merge pull request #2024 from Muratv07/feature/move-art-and-guides-together
feat: move and copy ruler guides and art together when selected
2026-10-16 04:35:38 +07:00
BFlat 0c9ce426e7 Merge pull request #2027 from lcslrx/feat/text-bundled-fonts-feature
Text: a `bundled-fonts` feature (on by default) lets embedders that bring their own fonts leave ours out
2026-10-16 04:35:17 +07:00
BFlat 98220d1929 Merge pull request #2220 from lcslrx/feat/render-svg-without-text-features
Render, SVG: vello_cpu and usvg without their text features: one skrifa, read-fonts and harfrust fewer in the engine
2026-10-16 04:35:01 +07:00
BFlat c141d54dad Merge pull request #2111 from IanHollow/codex/vectorcraft-document-fonts
Text: make every supplied font family available to documents
2026-10-16 04:34:43 +07:00
BFlat f16f542b9b Merge pull request #1944 from yukincom/codex/mcp-vertical-text
G6: expose vertical writing in MCP add_text
2026-10-16 04:34:26 +07:00
BFlat 0db9d6f757 Merge pull request #1946 from yukincom/codex/vertical-punctuation-upright
G4: keep fullwidth punctuation upright without vertical alternates
2026-10-16 04:34:07 +07:00
BFlat a23a682960 Merge pull request #1949 from ham9zah/curvature-illustrator-geometry
Curvature: each three points follow the circle through them, curved ends (#1236, in part)
2026-10-16 04:33:47 +07:00
BFlat 2d1e09cf32 Merge pull request #1975 from el-j/feat/direct-selection-double-click-type
Canvas: double-clicking type with Direct Selection edits it, and Escape returns to originating tool
2026-10-16 04:33:28 +07:00
BFlat 1838a4e89a Merge pull request #2117 from wstock/feat/2000-hide-template
View › Hide Template (Cmd+Shift+W): template layers leave the canvas and come back (#2000)
2026-10-16 04:33:08 +07:00
BFlat d02802f391 Merge pull request #2085 from Silvertree2010/fix/2049-layers-panel-does-not-reveal-the
Reveal the active layer in the Layers panel after a canvas selection
2026-10-16 04:32:51 +07:00
BFlat 20525c9346 Merge pull request #2150 from AyberkA0/docinfo-path-length
Document Info: Objects gives the paths' total length (#1958)
2026-10-16 04:32:34 +07:00
BFlat 3ece6428b3 Merge pull request #2083 from fabian-co/fix/panel-menu-hover
Panel menus light up under the pointer, like the menu bar's
2026-10-16 04:32:17 +07:00
BFlat e67a9f558a Merge pull request #2082 from fabian-co/feat/swatches-follow-selection
Swatches panel follows the selection's swatch
2026-10-16 04:32:01 +07:00
BFlat a9d72d01b1 Merge pull request #2081 from fabian-co/feat/image-replace
Replace a selected image from the Control bar, with its own aspect ratio
2026-10-16 04:31:45 +07:00
BFlat 81e2c81890 Merge pull request #2080 from fabian-co/feat/recolor-library-swatches
Recolor Artwork: library colours apply as their swatches; import a library
2026-10-16 04:31:30 +07:00
BFlat fe5c78f6a5 Merge pull request #2079 from fabian-co/feat/recolor-shortcuts
Recolor Artwork from the Contextual Task Bar and the Control bar
2026-10-16 04:31:13 +07:00
BFlat 57a3cfab73 Merge pull request #1930 from turtwig/fix/glyphs-preview-zoom
Glyphs panel: zoom buttons resize the preview and the step is kept
2026-10-16 04:30:54 +07:00
BFlat 4dcb0900a6 Merge pull request #2265 from andeecollard/tests/shared-closed-boundaries
Image Trace: regression tests for shared boundaries in Abutting traces
2026-10-16 04:30:35 +07:00
BFlat 80be476b04 Merge pull request #2264 from andeecollard/fix/trace-grayscale-gradients
Image Trace: grayscale gradients use grey stops
2026-10-16 04:30:29 +07:00
BFlat cddc5a6848 Merge pull request #2263 from andeecollard/fix/trace-seam-guard
Image Trace: keep shared corners identical at nearby junctions
2026-10-16 04:30:23 +07:00
BFlat d6f6cb9b5f Merge pull request #2025 from andeecollard/fix/trace-snap-collapsed-loops
Image Trace: preserve thin closed outlines after straightening
2026-10-16 04:30:15 +07:00
BFlat 5aab51bf87 Merge pull request #2012 from andeecollard/fix/trace-small-closed-contours
Image Trace: retain tiny components and holes after fitting
2026-10-16 04:29:57 +07:00
BFlat 3bcce6593a Merge pull request #2144 from dotCooCoo/fix/twisted-walls-depth-order
3D and Materials: twisted walls and bevels are drawn back to front
2026-10-16 04:25:17 +07:00
BFlat 192ca0b8e6 Merge pull request #2180 from dotCooCoo/feat/scribble-separate-variations
Effects: Scribble has a Variation for Path Overlap, Curviness and Spacing, and stores 270° as -90°
2026-10-16 04:25:00 +07:00
BFlat b64c88b4d9 Merge pull request #2154 from dotCooCoo/feat/smart-blur-edge-modes
Effects: Smart Blur's Mode menu has Edge Only and Overlay Edge
2026-10-16 04:24:44 +07:00
BFlat 78c5973609 Merge pull request #1925 from dotCooCoo/feat/clipboard-flavors-off-ui-thread
Clipboard: Copy and Cut make the system clipboard's formats on the background thread
2026-10-16 04:24:28 +07:00
BFlat 99a6c06681 Merge pull request #2032 from dotCooCoo/fix/issue-1876
Artboards: the options icon and the panel menu open Artboard Options
2026-10-16 04:24:10 +07:00
BFlat c42b31f48a Merge pull request #2039 from dotCooCoo/fix/pathfinder-effect-keeps-type
Effects: Effect › Pathfinder uses the glyph outlines of type in the group
2026-10-16 04:23:53 +07:00
BFlat 4f794ea8db Merge pull request #2056 from dotCooCoo/feat/perf-deep-peak-working-set
CLI: perf --deep reports the peak working set on Windows and the peak resident memory on Linux
2026-10-16 04:23:37 +07:00
BFlat cc467d1ef4 Merge pull request #2063 from dotCooCoo/feat/plugin-geometry-follows-registry
Render: objects with plug-in effects follow the plug-ins installed
2026-10-16 04:23:18 +07:00
BFlat bfe6670539 Merge pull request #2064 from dotCooCoo/fix/fit-artboard-active
Artboards: Fit to Artwork Bounds and Fit to Selected Art resize the active artboard
2026-10-16 04:23:01 +07:00
BFlat bf10fd64f6 Merge pull request #2065 from dotCooCoo/feat/auto-size-type-fewer-layouts
Type: Auto Size area type in columns usually lays the columns out twice an edit
2026-10-16 04:22:45 +07:00
BFlat 2346a4acfb Merge pull request #2067 from dotCooCoo/feat/alt-drag-copies-once-more-tools
Part of #1429: Alt-drags of objects and plane widgets in perspective copy the objects once per drag
2026-10-16 04:22:29 +07:00
BFlat 5b9a9b6d6c Merge pull request #2068 from dotCooCoo/fix/active-artboard-outline
Artboards: the canvas outlines the active artboard in black
2026-10-16 04:22:13 +07:00
BFlat 82d9143502 Merge pull request #2087 from dotCooCoo/feat/layers-panel-filter
Layers: the panel filters its rows by object kind, lock and visibility
2026-10-16 04:21:55 +07:00
BFlat cebc4ed93e Merge pull request #2093 from dotCooCoo/feat/presentation-mode-artboard-keys
View: in Presentation Mode, the left and right arrow keys and a click go between artboards
2026-10-16 04:21:32 +07:00
BFlat 29bf7b7e06 Merge pull request #2099 from dotCooCoo/feat/artboard-navigator-list
Status bar: the artboard navigator lists the artboards by number and name
2026-10-16 04:21:15 +07:00
BFlat e965c0daa2 Merge pull request #2102 from dotCooCoo/feat/properties-artboard-name-options
Properties: Edit Artboards renames the active artboard and opens Artboard Options
2026-10-16 04:21:00 +07:00
BFlat aaeb658292 Merge pull request #2106 from dotCooCoo/feat/rotate-view-menu
View: Rotate View turns the view to a preset angle, and `view.rotate` sets any angle
2026-10-16 04:20:42 +07:00
BFlat d541eb9476 Merge pull request #2116 from dotCooCoo/feat/export-screens-selected-artboards
Export for Screens: the dialog opens with the Artboard tool's selected artboards checked
2026-10-16 04:20:26 +07:00
BFlat cf0be249ba Merge pull request #2134 from dotCooCoo/feat/free-distort-dialog-corners
Effects: Free Distort's corners are dragged in its dialog
2026-10-16 04:20:09 +07:00
BFlat 0594f04e30 Merge pull request #2136 from dotCooCoo/feat/color-panel-options-command
Color panel: Hide Options comes back after a restart, and `ui.colorPanel` sets the mode and options
2026-10-16 04:19:47 +07:00
BFlat b15c28b7c0 Merge pull request #2138 from dotCooCoo/fix/glowing-edges-raster-stylize
Effect menu: Glowing Edges moves to a Stylize submenu under Raster Effects
2026-10-16 04:19:23 +07:00
Murat Vural 1ab3940b3d feat: move and copy ruler guides and art together when selected 2026-10-16 04:19:06 +07:00
Lucas LerouxandClaude Opus 5.5 2abc1d61f7 xtask ci: test vectorcraft-text without its bundled fonts
`cargo test --workspace` builds vectorcraft-text with `bundled-fonts`
(the engine turns it on), so the configuration embedders use without it
was tested only by hand. A `no-bundled-fonts` step after `test` runs its
integration test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:19:05 +07:00
Lucas LerouxandClaude Opus 5.5 1e8f5025c6 Render, SVG: vello_cpu and usvg without their text features: one skrifa, read-fonts and harfrust fewer in the engine
The default `text` features of vello_cpu (glyph runs, through glifo) and
usvg (fontdb, harfrust 0.12, skrifa 0.44 and its read-fonts 0.41) build
another copy of the font stack into the engine, next to the workspace's
skrifa 0.47 and harfrust 0.13. Neither is used: the renderer draws the
glyph outlines vectorcraft-text gives it, and SVG import reads <text>
from the XML itself and never gives usvg a font (usvg leaves out text it
has no font for).

- vello_cpu: its default features but `text` (std, png, u8_pipeline),
  plus f32_pipeline as before.
- usvg: `svgz` only, for gzipped SVG images inside an SVG; `text`,
  `system-fonts`, `memmap-fonts` and `writer` are off. text_layout.rs uses
  usvg as a reference viewer with a font, so crates/svg turns `text` on as
  a dev-dependency feature, as crates/pdf does for krilla's `simple-text`.
- A test pins `svgz`: SVGZ images, linked or as a data URL, import as
  vector art.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:19:05 +07:00
IanHollow 83f40f135a Text: make every supplied font family available to documents
Assisted-by: GPT-6
Signed-off-by: IanHollow <[email protected]>
2026-10-16 04:19:05 +07:00
YukiHiko 8b5e9e4927 G6: expose vertical writing in MCP add_text 2026-10-16 04:19:05 +07:00
YukiHiko 3a7d422046 G4: keep fullwidth punctuation upright without vertical alternates 2026-10-16 04:19:05 +07:00
ham9zah ad3531d464 Curvature: each three points follow the circle through them, curved ends (#1236, in part)
The Curvature tool built its curve as a uniform Catmull-Rom: both handles of a
smooth point a sixth of the chord between its neighbours, the ends of an open
path without handles. Uneven spacing overshot or looped, a J or U from three
points bent sharply at the middle with straight legs, and the end segments
looked flat.

- `curvature_spline` (draw2.rs) makes each stretch of three points follow the
  circle through them: a smooth point's tangent is that circle's
  (`smooth_handles`), each handle `chord / (3 cos²(α/2))` long, α between
  handle and chord capped at 90° (`arc_handle`). The ends of an open path
  mirror their neighbour's tangent in their chord (`end_handle`). A turn
  tighter than 30° (`TIGHT_TURN`) shortens its handles, so a narrow V comes to
  a point. Degenerate points become corners. `path.curvature` and the rubber
  band use it.
- `catmull_rom` is unchanged for the Pencil and Paintbrush fitting.
- `bend_last` (from: smooth), `curvature_move` on a smooth point, the point
  `curvature_insert` adds on a straight segment, and `path.convertAnchor` to
  smooth without x/y (`curvature_smooth`) use the same rule.
- `curvature_extend` gives the new end its end handle unless Alt-clicked
  (`corner` param on `path.curvatureEdit`).
- Neighbours of a point dragged on an existing path keep their tangents (a
  Pen-drawn shape isn't reshaped); the path being drawn re-curves throughout.
2026-10-16 04:19:05 +07:00
el-j 4913418d1c Type tool: Escape returns to the previous selection tool and preserves isolation mode
When entering text edit mode via double-click from a selection tool
(Selection or Direct Selection), pressing Escape now returns to the specific
tool used before double-clicking (rather than always falling back to
Selection).

Additionally, when editing text while in isolation mode (such as when drilling
into nested groups with the Selection tool), pressing Escape exits text editing
while keeping isolation mode active; a second Escape press is required to exit
isolation mode.

- Record the previous tool in TypeTool on double-click from canvas.
- Return to prev_tool on ToolKey::Escape, consuming the key in shortcuts when claimed.
- Add unit test in vectorcraft-tools verifying Escape returns to prev_tool.
- Add integration tests in vectorcraft-ui-egui verifying Escape returns to Direct
  Selection and that isolation mode persists until a second Escape.
- Update docs/target-app-parity.md and ROADMAP.md.
2026-10-16 04:19:05 +07:00
bflatastic 9e1590769e i18n(bg): Hide Template and Show Template 2026-10-16 04:19:05 +07:00
Lucas LerouxandClaude Opus 5.5 55fadb6afe Text: a bundled-fonts feature (on by default) lets embedders that bring their own fonts leave ours out
The nine OFL faces in `assets/fonts` are compiled into every build of
vectorcraft-text: 3.4 MB of the binary, about 1.6 MB of a gzipped wasm.
An embedder that loads every font at run time with `FontDb::add_font`
(a web app serving its own font catalogue, say) ships them twice and
can't turn them off.

- vectorcraft-text: `bundled-fonts`, a default feature, holds the
  `include_bytes!` list. Off, a database starts without them (craft-fonts
  and system fonts are unaffected) and text has no glyphs until a font
  is added, as when the bundled fonts fail to parse.
- vectorcraft-engine: a `bundled-fonts` default feature forwards to it,
  so the apps and any embedder using the engine keep the fonts unless
  they build it with `default-features = false`.
- The workspace declares vectorcraft-text without default features, so
  no library crate turns the fonts back on. The tests of cad, metafile
  and tools, which lay type out, ask for the feature in their
  dev-dependencies; the other crates' tests get it through testkit.

`cargo test --workspace` builds with the fonts, as before. A new
integration test, `without_bundled_fonts`, covers the configuration
without them (`cargo test -p vectorcraft-text --no-default-features
--test without_bundled_fonts`): the database starts without them, text
in no font lays out with no glyphs, and a font added later is used and
is the fallback for families the database lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:19:05 +07:00
el-j 28daaf473c Canvas: double-clicking type with the direct selection tool puts the caret there
With the Direct Selection tool ("white arrow", shortcut 'a'), double-clicking
a text element (either standalone or nested inside a group) now switches to
the Type tool and immediately positions the caret at the clicked character,
matching the behavior previously introduced for the Selection tool in #284.

- In DirectSelectionTool::pointer, detect double-click hits on NodeKind::Text
  when !self.group and emit Action::SwitchTool("type").
- Group Selection (group: true) retains its existing group cycling behavior.
- Add unit test in vectorcraft-tools verifying tool switch on both standalone
  and grouped text.
- Add headless egui integration test in vectorcraft-ui-egui verifying tool switch,
  caret positioning, and inline editing without triggering shortcuts.
- Update docs/target-app-parity.md and ROADMAP.md.
2026-10-16 04:19:05 +07:00
BFlat fc51d66659 Merge pull request #2140 from dotCooCoo/feat/save-template-ait
Templates: Save as Template can also write a PDF-compatible .ait
2026-10-16 04:19:04 +07:00
wstockandClaude Fable 5.1 7b8730ec14 View › Hide Template (Cmd+Shift+W): template layers leave the canvas and come back (#2000)
Illustrator's View menu has Hide/Show Template (Shift+Ctrl+W) to leave
template layers off the screen while tracing. VectorCraft drew them (dimmed)
whatever the view, with no way to hide them short of disabling the template
option on each layer and losing the dimming.

- `view.template` toggles `ViewFlags::templates` (default on, kept across
  settings files); the View menu lists Hide Template after Lock Slices, and
  the label flips to Show Template.
- The canvas renders with the new `RenderOptions::hide_templates`, which
  leaves template layers out and nothing else; `skip_templates` (exports and
  thumbnails) also drops guide paths, so it is not reused.
- The fifteen menu catalogs translate both labels; `DYNAMIC_LABELS` lists them.

Tests: render `hide_template_leaves_only_the_template_layers_out` (the
template layer's square and image go, a guide path on another layer stays);
canvas `hide_template_leaves_template_layers_off_the_canvas` (real egui
frames: painted pixels, none after Hide Template, the same after Show
Template, each re-rendering once); the dynamic-label and catalog tests cover
the new pair.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-16 04:08:44 +07:00
NetterSilverandClaude Haiku 5.5 c54a9b0bef Reveal the active layer in the Layers panel after a canvas selection
Fixes #2049

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:42 +07:00
Ayberk A. a86ff7f177 Document Info: Objects gives the paths' total length (#1958)
Document Info › Objects counts the paths but didn't say how long they are,
which sign and print work reads there (an LED strip along the letters, a
cut line). `document.info` now adds up the length of every path it counts,
the pieces of compound paths included and guides left out, and returns it
as `pathLength` (pt); the Objects section shows it as Path Length in the
document's units, right after the Paths count, so Selection Only gives the
selected paths' length. The label is translated in the catalogs that
cover the menus; pt-br only takes strings the UI itself shows.
2026-10-16 04:08:12 +07:00
Fabian GrajalesandClaude Opus 5.5 7135a377f8 Panel menus light up under the pointer, like the menu bar's
Panel (≡) menu rows, and the Control bar's image menu, had no frame at all,
so nothing showed which row the pointer was on. They now draw the hover
frame the menu bar's items draw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:10 +07:00
Fabian GrajalesandClaude Opus 5.5 91e1434cbf Swatches panel follows the selection's swatch
Selecting art scrolls the tiles to its swatch once (centred) and clears the
panel's own selection, so the art's swatch is the one highlighted; so does a
change of the selection's paint (after Recolor Artwork). An unlinked process
colour highlights the swatch of that very colour. A selected image, or text
in a colour no swatch has, highlights nothing (not None).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:08 +07:00
Fabian GrajalesandClaude Opus 5.5 5a43337a42 Replace a selected image from the Control bar, with its own aspect ratio
The Control bar's file name is a menu: Replace…, Go To Link, Edit Original,
Update Link and Placement Options…. Replace… (also in the Properties panel,
in place of Relink…) puts the picked file in the image's place fitted inside
the old bounds and centred, keeping the new file's aspect ratio instead of
stretching it into the old one. links.relink takes `preserve` (transforms,
bounds, fileDimensions, fit or fill) to override the Placement Options for
that read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:06 +07:00
Fabian GrajalesandClaude Opus 5.5 5252c3d86a Recolor Artwork: library colours apply as their swatches; import a library
With Limit to Library, a row's new colour is the library swatch itself: art
links to a global or spot colour (a Pantone-style spot colour stays a spot
colour in its own model, tints keep their tint) and a process swatch gives
its colour in its own model, instead of being converted to the model of the
colour it replaces. The swatches used join the document's swatches (one the
document already has, same name and colour, is reused); recolor.apply
reports them as `swatches`. A folder button next to Limit to Library loads a
library file (.ase, .acb, .gpl, .vcswatches or a document's swatches) and
limits to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:04 +07:00
Fabian GrajalesandClaude Opus 5.5 1ba448f7f3 Recolor Artwork from the Contextual Task Bar and the Control bar
A palette button opens Recolor Artwork when the selection has colours to
recolour (solid colours, gradient stops or mesh points, inside groups too):
in the Contextual Task Bar, before Lock, and in the Control bar, after the
Graphic Style picker. Art painted with None shows no button.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:01 +07:00
turtwig 032b7b7be1 Glyphs panel: zoom buttons resize the preview and the step is kept
Zoom Out and Zoom In sit to the right of the font family and style menus. They step the preview cells through 16, 22, 30, 42, 56, 72 and 96 pt; the grid still opens at 30 pt, and the buttons stop at the ends. The step is stored on the UI state and written with the other panel choices in ui.json when the app quits. Zero is the default and is left out of the file, so older preferences stay as they are.
2026-10-16 04:07:59 +07:00
andeecollardandClaude Opus 5.5 8fffa4f6f9 Image Trace: regression tests for shared closed boundaries
Abutting traces must draw each edge between two neighbouring regions once:
the filled paths on either side follow one curve, one of them reversed, and
together cover the image without gaps or overlaps. These tests check that
from the trace output alone, with no renderer: points along each path's
outline must lie within 0.001 px of another path's outline, the paths' areas
must sum to the image's, and every point of a 4 x 4 per-pixel grid must lie
in exactly one path.

The images are drawn in code, hard-edged and anti-aliased, and traced in
Color (Abutting) and Black and White, each at the default settings, Paths and
Corners 0 and 100, Snap Curves To Lines, and Shapes. They cover a closed island
in a background, a ring with and without a core of a third colour, a wavy
two-colour seam, a three-region junction, outlines that leave a junction and
return to it (the 111 / 101 / 110 pattern at 1, 2, 3 and 6 px, and framed),
squares touching at a corner, a one-pixel hole and a one-pixel island (kept at
Noise 1, dropped together with what they leave at Noise 25), separate areas of
one colour, and slanted hatches with one-pixel paper channels. A further test
shows the check finds a 0.01 px shift and stacked (Overlapping) layers.

Two tests are ignored as documented current gaps:
- At Paths 0 and Corners 0, a piece returning to its own junction collapses:
  its region is dropped and its area left uncovered (draft #2205 fixes it).
- With Shapes, a region whose outline runs through a junction becomes an exact
  shape its neighbour does not share (up to 0.0039 px in Black and White).

No tracer code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:57 +07:00
andeecollardandClaude Opus 5.5 dbcd32d21a Image Trace: grayscale gradients use grey stops
In Grayscale mode with Gradients on, the trace could fill paths with
coloured linear gradients (for example a blue-to-orange ramp) beside
grey flat fills. Gradient detection ran on the RGB image, so the stops
and the gradient's palette colour (its middle) were the source colours,
while Grayscale's flat fills come from each pixel's luma.

Grayscale now finds gradients, and settles their edges, in a copy of the
image reduced to the same Rec. 601 luma the flat grays are quantised
from. Every channel of that copy is equal, so the fitted stops, the ends
carried to the area's edges and the palette entry are all grey. Color
mode is unchanged: it still detects on the RGB image (trace output for
gradient fixtures is byte-identical before and after).

Adds grayscale_gradients_are_grey: a code-drawn sky ramp over flat
shapes with a ramp rectangle, traced in Grayscale with 4 grays and
Gradients on, must still produce a gradient, and every path colour,
gradient stop and palette entry must be grey.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:55 +07:00
andeecollardandClaude Opus 5.5 5d8b5ddc6c Image Trace: keep shared corners identical at nearby junctions
When a long horizontal or vertical run crosses a three-colour junction one
or two pixels before a corner, the corner correction for that run borrowed
a neighbouring crack's shift that the region on the other side of the
junction never applied, leaving hairline gaps between abutting colours.
The correction now applies only when all three cracks it uses lie in the
same shared piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:53 +07:00
andeecollardandClaude Opus 5.5 75b91e9ef3 Docs: leave the status lines to main
The 'Last updated · Change' status lines in docs/roadmap.md, docs/target-app-parity.md and docs/image-trace-quality.md change with almost every merge to main, so this PR's edits to them conflicted each time main moved. They are reverted to main's text; this PR's evidence bullets, history rows and table entries stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:51 +07:00
andeecollard 4714567240 M12: retain the interior of collapsed closed trace outlines 2026-10-16 04:07:50 +07:00
andeecollardandClaude Opus 5.5 477ff16357 Docs: leave the status lines to main
The 'Last updated · Change' status lines in docs/roadmap.md, docs/target-app-parity.md and docs/image-trace-quality.md change with almost every merge to main, so this PR's edits to them conflicted each time main moved. They are reverted to main's text; this PR's evidence bullets, history rows and table entries stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:45 +07:00
andeecollard c7aac7c250 M12: preserve tiny traced components and holes 2026-10-16 04:07:45 +07:00
bflatastic fd8cd62d17 i18n(bg): rows for the Layers panel filters and Color Panel Options 2026-10-16 04:04:51 +07:00
dotCooCoo 04ed30a23d Effects: Scribble has a Variation for Path Overlap, Curviness and Spacing, and stores 270° as -90°
The reference app's Scribble Options have a Variation for each of Path
Overlap, Curviness and Spacing. Scribble had one `variation`, which
moved each line's ends both along the line and across it, and every
turn of a scribble took the same curviness. An angle such as 270 was
stored as given.

Scribble now takes `overlapVariation` (pt, default 0.5), which moves
the ends along the line, `spacingVariation` (pt, 0.5), which moves
them across it, and `curvinessVariation` (%, 0), which varies the
curviness from one turn to the next. The dialog shows each Variation
after the option it varies, and the Appearance panel lists all three.
A Scribble saved or scripted with the earlier `variation` reads it as
the Path Overlap and Spacing variations. With the defaults, and with
the earlier `variation`, Scribble draws the same outline as before.
`effect.apply` and `effect.setParams` store an angle above -180 and up
to 180, so 270 is stored as -90, and the dialog shows the stored value.

The Appearance panel's inline editor lists an effect's values in
their current form, as the dialog does. It listed an earlier key next
to the defaults of the keys that replace it.

Docs: docs/effects-parity.md (Stylize row), docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 6f9ee7825b Effects: Smart Blur's Mode menu has Edge Only and Overlay Edge
Effect › Blur › Smart Blur had only its Normal mode: the dialog and
blur.smart took Radius, Threshold and Quality and drew the blur.

Smart Blur now takes a `blurMode` param ("normal", "edgeOnly" or
"overlayEdge"; "normal" by default), which its dialog shows as a Mode
menu. Edge Only draws the edges in white on black, and Overlay Edge
draws them in white over the Normal 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. Normal draws the same pixels as before, and an effect saved
without `blurMode` draws as Normal.

Docs: docs/effects-parity.md, docs/gaps.md, docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 580aa8daa5 Clipboard: Copy and Cut make the system clipboard's formats on the background thread
Copy and Cut made the system clipboard's formats in the frame that ran
them. The formats include the SVG markup and a PNG of the selection at
1 px per pt, up to 4,096 px a side. In a release build on Windows, Copy
of 50,002 rectangles on a 1600 x 1200 pt page took 1.00 s in its frame.

The thread that writes background saves and exports now makes the
formats from a copy of the clipboard, and the app writes them in a
later frame. The same Copy takes 0.18 s in its frame, and the SVG and
PNG are the same bytes. While that thread has a save, an export, a print or
a recovery copy to finish, Copy and Cut make and write the formats at
once. A later Copy or Cut, or text copied in a field or with the Type
tool, drops formats that are not written yet. Until they are written,
Paste on the canvas takes the internal clipboard, and while a text
field or the Type tool has the keyboard the app waits for them.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo f14fbded0e Artboards: the options icon and the panel menu open Artboard Options
In the Artboards panel, a click or a double-click on a row's options
icon (the page at the right end of the row) chose the Artboard tool on
that row's artboard and opened no dialog. The panel menu's Artboard
Options… also only chose the Artboard tool.

A click on the active artboard's icon, or a double-click on another
row's icon, now opens Artboard Options for that row's artboard. The
first click on another row's icon makes its artboard active, as a
click on the row does. The panel menu's Artboard Options… opens the
dialog for the active artboard. Both run tool.options with tool
"artboard", as a double-click on the Artboard tool's button does, and
they no longer choose the Artboard tool. The icon's tooltip is now
Artboard Options, and the old tooltip's row is removed from the 15
catalogs.

Opened with another tool, Artboard Options did not move the art with a
new X or Y. It now moves the art when the Artboard tool's Move Artwork
with Artboard option is on, as it does while that tool is in use.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 446ccb1fd0 3D and Materials: twisted walls and bevels are drawn back to front
At 180° and 360° of twist, Extrude & Bevel and Inflate drew some wall
strips in the wrong order, and a far strip showed through a near one.
Each strip ran the whole length of an outline edge and was sorted by
the depth of its center, while a twisted wall can cross it far from
that center. The caps were sorted the same way.

Twisted walls and bevels are now drawn in pieces about as long as a
wall slice is wide (fewer near the 32,768-facet limit), and twisted
bevel steps turn at most 6° each, as wall slices do. A cap, or
Inflate's flat back, that faces the viewer is drawn after the walls,
and edge-on pieces of a twisted wall keep their own depth. Pieces that
face the viewer are drawn where their strip as a whole faces away, so
a twisted solid seen from behind shows its walls' corners around the
back cap. Untwisted solids give the same facets as on main.

Over 80 twisted squares, the facet seen over a point lay up to 79.7 pt
behind a nearer facet on main and now lies at most 5.0 pt behind.
"Twisted" in 72 pt type, extruded with a 360° twist, takes 12,311
facets instead of 7,622 and 3.1 ms instead of 1.9 ms.

Docs: docs/three-d.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 4440463315 Effects: Effect › Pathfinder uses the glyph outlines of type in the group
Effect › Pathfinder left type in the group out of all ten operations,
on the canvas, in Expand Appearance and in exports. The text outliner
returns type as a group with one path per run. `leaves` in
crates/effects/src/group.rs took that group as one leaf, and
`geometry` gives no path for a group. A 100 x 100 pt square grouped
with a 100 pt "H" inside it kept all of its 10,000 pt² after Subtract
and Expand Appearance. In the shape modes a member group with type in
front also lost its paint, because it took the style of the outline
group, which has none.

`leaves` now takes the paths and compound paths of the outline art at
every depth, as it does for a member group, and gives each one the
type's opacity and blend mode. The same square keeps 8,684.88 pt² after
Subtract and Expand Appearance, its 10,000 pt² less the glyph's
1,315.12 pt².

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 6928f422d3 CLI: perf --deep reports the peak working set on Windows and the peak resident memory on Linux
perf --deep printed the memory at eight points of its run, and no row
showed memory that the run took and freed between two readings. With
--paths 2000, a release build on Windows printed 430 MB as its largest
row, and the process's peak working set was 612 MB. On Windows the
memory row after rendering the photos also printed its value 3 columns
right of the others: its name is 55 characters, and the name column is
52 characters wide. The tracked row for the photos printed its value 2
columns right of the other tracked rows: its name is 54 characters, and
their name column is 52 characters wide. In the budget table the handle
and segment drag rows printed their values 1 and 2 columns right of the
other rows: their names are 41 and 42 characters, and the name column
is 40 characters wide.

Each memory row now also gives the peak so far: the peak working set
(PeakWorkingSetSize, from the same GetProcessMemoryInfo call) on
Windows, and VmHWM from /proc/self/status on Linux. On macOS the rows
give no peak. A new last row gives the memory after the SVG export and
the save → open → save at the end of the run. The name columns of the
memory rows, the tracked rows and the budget table are 56, 54 and 42
characters wide. The help says on which systems the rows give the peak.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo c6837ca3ae Render: objects with plug-in effects follow the plug-ins installed
The renderer kept the shapes it drew for plug-in effects while the
object stayed the same. When a plug-in was installed for the first
time, installed again or removed, a renderer that had drawn the object
went on drawing the shapes from before the change.

When the plug-in registry's revision changes, the renderer now drops
the effect geometry and the effect art of objects with a plug-in
effect, and the cull bounds of every object that is or holds a symbol
instance or an object with a plug-in effect. Each thread then drops the
pattern tiles whose art has a plug-in effect or shows symbols or
patterns. The renderer adds the revision to the inputs of a raster
when its content has a plug-in effect, or shows symbols or patterns
while the document's symbol or pattern art has one, and to the key of
a live object's evaluation when the object, or an object it shows, has
a plug-in effect.

With other builds running, a temporary release test timed that key for
the 400 live objects in each of three documents at 11 to 609
microseconds a frame, against 9 to 433 before.

Docs: docs/target-app-parity.md, docs/gaps.md.
2026-10-16 04:04:50 +07:00
dotCooCoo b34b9dadcc Artboards: Fit to Artwork Bounds and Fit to Selected Art resize the active artboard
Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art ran
artboard.fitToArt and artboard.fitToSelection without an index, so
they resized artboard 1 whichever artboard was active. The Artboards
panel menu's Fit to Artwork Bounds already passed the active artboard.

The app now passes its active artboard as the index of the two
commands when a call names none, as it passes the artboard to Align to
Artboard, All on Active Artboard and Paste in Place, in Front and in
Back. That covers the menu items, keyboard shortcuts, the command
palette and MCP's run_command in remote mode. A batch passes its
artboard as the index to their steps that name none, and a recorded
action leaves the index out, so an action resizes the artboard that is
active when it plays. The params texts of the two commands and of
command.batch describe where the index comes from.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 867e72b085 Type: Auto Size area type in columns usually lays the columns out twice an edit
For Auto Size area type in columns, every text edit, each keystroke of
the Type tool included, laid the text out in one column and then laid
the columns out at that height and at each step of a bisection for the
least height that holds the text. In the release-build timing test
auto_height_in_columns_typing_timing (stories of 5,000 to 50,000
characters at 9 pt in a frame 500 pt wide), a keystroke laid the
columns out 18 to 22 times, and typing at the start of the story of
50,000 characters in 2 columns took 1,670 ms a keystroke.

The bisection now runs on the lines of the one-column layout.
columns_hold computes where those lines would sit in the columns, with
each column's first line at its first baseline below the column's top
(LineInfo::first_baseline, new in vectorcraft-text). The columns are
then laid out at the upper height the bisection ends on and, when they
hold the text there, at the lower one, unless that is the 1 pt it starts
from. When they hold the text at the upper height and not at the lower
one, Auto Size takes the upper height. When taller columns always hold
the text, the old search ends between the same two heights. When the
text wraps around other objects or does not fit the one-column layout,
or when that check fails, the columns are laid out at every step as
before. The same keystroke now takes 292 ms.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 0ffae0c3fa Part of #1429: Alt-drags of objects and plane widgets in perspective copy the objects once per drag
On each pointer move of an Alt-drag with the Perspective Selection
tool, Session::preview put the drag's snapshot back and ran
perspective.move with copy again, and persp_edit copied every selected
object again. An Alt-drag of a plane widget with the Perspective Grid
or Perspective Selection tool ran perspective.plane.move with objects
"copy" again, and plane_move copied every object on the plane again.

Both commands now take their copies from preview_copies, as
apply_transform does for the Selection and transform tools: the first
preview copies the objects into a clone of the snapshot, and each later
preview of the same objects on the same snapshot maps only those
copies. The copies keep their ids, and the document, the selection,
the undo step and the journal are the same as before. When the stored
grid still lists attachments of its own, as grids saved before
attachments were kept on the objects do, both commands copy on every
preview as before. With 25,000 rectangles attached to the left plane,
a release build of vectorcraft-cli took a median of 424.8 ms for each
Alt-drag move of the objects after the first preview and takes
328.9 ms with this change. An Alt-drag of the plane's widget took
340.3 ms a move and takes 256.9 ms.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 9e832d32da Artboards: the canvas outlines the active artboard in black
The canvas drew artboard 1's edge in black, 1 px wide, whichever
artboard was active, and drew every other artboard's edge in gray,
0.6 px wide.

It now draws the active artboard's edge in black and draws the other
artboards' edges in gray, at the same widths. The active artboard is
the artboard that the status bar's navigator shows and that Align to
Artboard and Paste in Place use. With several artboards selected by
the Artboard tool, it is the one the tool draws with handles and its
name.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 29eb490381 Layers: the panel filters its rows by object kind, lock and visibility
The Layers panel narrowed its rows by name (Search All) or to layers
alone (Show Layers Only).

The panel now has a Filter button at the right of Search All. Its
popup holds Clear All and a check box for each object kind under By
Object (Paths, Shapes, Text, Images, Effects, Groups, Linked Files,
Guides) and for each row state under By Layer (Locked, Hidden). A row
stays when it is of a ticked kind, is in a ticked state and has the
search text in its name, and a section with nothing ticked keeps every
row. The rows that hold a kept row stay too, as they do for the
search. The button is lit while a filter is on. The filter lasts the
session and does not change the document, its open rows or the
selection. ui.layersFilter {objects?, layers?} sets it. The button
shows a new icon, assets/icons/dc-filter.svg, with its row in
ASSETS.md.

The panel lists its rows again when the filter changes, as it does
when the search changes. On a layer of 5,000 groups of 10 paths, a
release build lists the rows for a filter in 2.9 to 3.6 ms.

Docs: docs/mcp.md (Layers panel), docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 697f238b60 View: in Presentation Mode, the left and right arrow keys and a click go between artboards
In Presentation Mode the arrow keys moved the selection, and the
active tool received a click and selected or drew as it does in
Normal Screen Mode. No key or click moved the view to another
artboard.

Left Arrow and Right Arrow now go to the previous and the next
artboard, and a click anywhere goes to the next one, through
view.goToArtboard, as the status bar navigator's Previous and Next
buttons do. On the first artboard Left Arrow leaves the view as it
is, and on the last artboard Right Arrow and a click leave it as it
is. No arrow key moves the selection in the mode, and the tool gets
no press, drag or hover, so a click selects and draws nothing. A drag
with the middle button or with Space held no longer pans the view
there; the mouse wheel still zooms and scrolls it.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 8ca25c955e Status bar: the artboard navigator lists the artboards by number and name
The status bar's artboard navigator showed only the active artboard's
number between its first, previous, next and last buttons, and it had
no list of the artboards.

The number is now a drop-down field that shows the active artboard's
number and name. Its list shows every artboard by number and name, as
the Artboards panel lists them. Choosing one runs view.goToArtboard,
the command the buttons run, which makes that artboard the active one
(and the Artboard tool's, while that tool is in use) and fits it in
the window. The list lays out only the rows in view and at most one
more, and opens with the active artboard's row in view. In a test
build with 1,000 artboards, the two frames that open the list take
0.12 ms and 0.04 ms, and a frame with the list open takes 0.03 ms. The
timed frames are headless and draw only the status bar.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 5a5f1a0995 Properties: Edit Artboards renames the active artboard and opens Artboard Options
With the Artboard tool, the Properties panel showed the active
artboard's name as a label, and no control in the panel opened
Artboard Options.

The name is now a text field. Typing a new name and pressing Enter or
clicking elsewhere renames the active artboard in one undo step
through artboard.setProps, as the Artboards panel's rename does;
pressing Escape or leaving the field empty keeps the old name. A click
on the Artboard Options icon beside the field runs tool.options with
tool "artboard", which opens Artboard Options for the active artboard,
as a double-click on the Artboard tool's button does.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 799155cdd0 View: Rotate View turns the view to a preset angle, and view.rotate sets any angle
In the View menu, Reset Rotate View was the only rotation item. The
status bar's angle menu set its twelve preset angles by writing the
view's rotation itself, and no command took an angle to turn the view
to.

View › Rotate View now lists the status bar's angles (0°, 15°, 30°,
45°, 60°, 90°, 180°, -15°, -30°, -45°, -60° and -90°) and checks the
one the view is turned to. The new UI command `view.rotate {angle}`
turns the view to any angle about the center of the canvas and
returns the angle it set, normalized to (-180, 180] as a Rotate View
drag normalizes it. The menu's items and the status bar's angle menu
run it from one list of angles; the status bar sets the same angles as
before. Without a document the command returns "no document" and the
items are disabled. Run from the command palette, which passes no
angle, Rotate View opens a dialog that asks for the angle, with the
view's current angle filled in.

The de, it, ja, ko, pl, pt-br, ru, uk, vi and zh-hant catalogs have a
new "Rotate View" row. The strings the catalogs cover leave out the
angle labels, as they leave out Type › Size's point sizes.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo 1f6ef9dcf3 Export for Screens: the dialog opens with the Artboard tool's selected artboards checked
Export for Screens opened on the artboard choice the document last
exported with (all artboards by default), whichever artboards were
selected with the Artboard tool or in the Artboards panel.

While the Artboard tool is in use, File › Export › Export for Screens…
now opens with the artboards the tool has selected checked, and Export
Artboard writes only those. They are the artboards the Artboards panel
highlights, and they are checked even after a Full Document export.
With another tool in use, the dialog still opens on the saved artboard
choice. The other fields show the saved settings in both cases, and the
params text of file.exportForScreens describes the rule.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo fc0e926cda Effects: Free Distort's corners are dragged in its dialog
Free Distort's dialog showed the four corners as read-only text, and
so did the Appearance panel's inline editor. Only effect.apply and
effect.setParams could set them.

The dialog now shows a box in the proportions of the first selected
object's bounds with a handle on each corner. Dragging a handle moves
that corner, and the preview follows. A handle focused by a click or
Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset
puts the four corners back on the box. Each handle has a
screen-reader name. On the corners row of the Appearance panel's
inline editor, Edit… opens the dialog.

Docs: docs/effects-parity.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo 940f425a01 Color panel: Hide Options comes back after a restart, and ui.colorPanel sets the mode and options
Show Options and Hide Options were kept in egui's temporary memory, so
the Color panel showed its options again after every restart. No
command set them or the panel's mode.

- `UiState::color_hide_options` holds Hide Options and is saved with the
  preferences. Its reader reads `true` as hidden and any other value as
  shown, so a bad value leaves the other preferences as they are.
- `ui.colorPanel {mode?, options?}` picks a mode as the panel menu does
  (the active color converts to the mode's model as one undo step) and
  shows or hides the options. It answers `{mode, options, shown}`. A mode
  the menu does not list, `lab` included, answers an error. The panel
  menu's Show Options and Hide Options call the command's function.
- `ui.inspect` reports the setting as `ui.color_hide_options`.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo ea59b066d4 Effect menu: Glowing Edges moves to a Stylize submenu under Raster Effects
On main, Glowing Edges… is listed in the Effect menu's vector Stylize
submenu, after Drop Shadow, Inner Glow, Outer Glow and Feather. The
reference app lists it in the Stylize submenu of its Photoshop effects,
below the vector effects.

The Effect menu now has a Stylize submenu under Raster Effects, between
Sharpen and Texture, and Glowing Edges is listed there. The menu sends
the effects at the Stylize path that PIXEL_EFFECTS names to that
submenu, and the vector Stylize submenu keeps its six effects. The
effect's id, its catalog entry, its effect.list entry and Apply Last
Effect do not change.
2026-10-16 04:04:49 +07:00
dotCooCoo 427cd65f30 Templates: Save as Template can also write a PDF-compatible .ait
File › Save as Template always wrote a .vctemplate. The save panel
offered only that type, file.saveAsTemplate ignored its format param,
and a name typed with .ait got .vctemplate added. New from Template
already opened a .ait as an untitled document.

Save as Template now also writes a .ait: the PDF-compatible file Save
As writes for .ai, carrying the document. The desktop save panel lists
VectorCraft Template first and the .ait type beside it, and the web
build's Save as Template dialog gets a Format menu with the same two
types. file.saveAsTemplate writes .ait for format: "ait", or for a path
ending in .ait when no format is given, and takes the .ai options;
file.formatOptions answers for ait. .vctemplate stays the default.
Exports and Export As still do not write .ait.

Docs: docs/mcp.md, docs/file-format-parity.md (.ait row),
docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
BFlat ed96a12196 Merge pull request #2201 from dotCooCoo/fix/paste-copied-svgz
Clipboard: a .svgz file copied in a file manager pastes as its art
2026-10-16 04:02:01 +07:00
BFlat be97caf90b Merge pull request #2148 from dotCooCoo/fix/paste-ai-file
Clipboard: a .ai or .ait file copied in a file manager pastes as its art
2026-10-16 04:01:44 +07:00
BFlat c48aba77e3 Merge pull request #2149 from dotCooCoo/fix/postscript-message-and-params
PDF/.ai import: the PostScript file error names File › Open and File › Place, which read the file
2026-10-16 04:01:28 +07:00
BFlat 6a56c7a5f6 Merge pull request #2202 from EricKuck/fix/pdf-shading-curve-stops
PDF import: an axial or radial shading keeps its function's curve when sampled into stops
2026-10-16 04:01:11 +07:00
BFlat 4a6d52e7ea Merge pull request #2218 from dotCooCoo/fix/same-value-spot-inks
PDF/.ai import: spot inks with the same values and different names keep their own swatches
2026-10-16 04:00:54 +07:00
BFlat fda7c9fae3 Merge pull request #2129 from dotCooCoo/fix/pdf-page-user-unit
PDF/.ai import: a .ai whose page has a UserUnit opens with its layers at the page's size
2026-10-16 04:00:36 +07:00
BFlat 0b10aa67a8 Merge pull request #2199 from dotCooCoo/fix/ai-cmyk-images-keep-samples
.ai import: CMYK images in the editing data keep their ink amounts
2026-10-16 04:00:18 +07:00
BFlat 2133a21096 Merge pull request #2051 from EricKuck/fix/ai-mesh-edge-points
AI import: a gradient mesh patch's edges bend through the anchors listed between its corners
2026-10-16 03:59:59 +07:00
BFlat ca8fb9a543 Merge pull request #2058 from EricKuck/fix/ai-pattern-gradient-canvas
AI import: a gradient in a pattern's definition is placed from the canvas, as the file's PDF part draws it
2026-10-16 03:59:49 +07:00
BFlat faba555c63 Merge pull request #2052 from EricKuck/fix/ai-styled-object-keeps-look-transparency
AI import: a styled object read in place of its drawn look keeps the transparency written on the look
2026-10-16 03:59:31 +07:00
BFlat 97d7e7488a Merge pull request #2050 from EricKuck/fix/ai-blurred-art-drawn-look
AI import: a drawn look that is an image stands for its object, as one of paths does
2026-10-16 03:59:15 +07:00
BFlat 029ab098a3 Merge pull request #2046 from EricKuck/fix/ai-clip-path-in-group
AI import: a clipping path alone in a group clips the clipping group around it
2026-10-16 03:58:57 +07:00
BFlat 2422d0ea5b Merge pull request #2030 from midasmatterhorn/fix/issue-2004-ai-opacity-mask
eps: an opacity mask in the editing data is named, not drawn whole (#2004)
2026-10-16 03:58:38 +07:00
BFlat e24edbab41 Merge pull request #2029 from midasmatterhorn/fix/issue-2003-ai-layer-opacity
eps: a layer's own transparency in the editing data is read (#2003)
2026-10-16 03:58:21 +07:00
BFlat 16af375873 Merge pull request #2060 from Silvertree2010/fix/2005-pdf-ai-import-a-document-with-on
Open PDFs and .ai files with an image over 256 MB decoded
2026-10-16 03:58:01 +07:00
dotCooCoo c246fc2f0e Clipboard: a .svgz file copied in a file manager pastes as its art
Paste takes a file copied in a file manager by its flavor, which
file_flavour tells from the file's first 4,096 bytes through
fileio::detect. detect names gzip data SVGZ unless it is a compressed
native file, and file_flavour gave no flavor for the format id svgz.
So a copied .svgz file did not paste as its art, although
clipboard.importSvg, the SVG flavor's command, reads SVGZ data.

A copied file whose gzip data unpacks to SVG markup now pastes through
the SVG flavor. file_flavour unpacks the head it is given with the
gzip decoder the SVGZ reader uses, up to where the head ends, and
checks the first 4,096 unpacked bytes with looks_like_svg, as detect
checks an uncompressed file, reading them as UTF-16 after a UTF-16
byte order mark. Other gzip files get no flavor, as before.

Docs: docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
dotCooCoo c5a1504e99 Clipboard: a .ai or .ait file copied in a file manager pastes as its art
Paste takes a file copied in a file manager by its flavor, which
file_flavour tells from the file's first 4,096 bytes through
fileio::detect. detect names PDF or PostScript bytes in a file called
.ai or .ait by that extension, and file_flavour gave no flavor for
the format ids ai and ait. So a copied .ai or .ait file did not paste
as its art, while the same bytes in a .pdf or .eps file did.

A copied .ai or .ait file whose bytes are a PDF now pastes through the
PDF flavor (clipboard.importPdf), and one whose bytes are PostScript
pastes through the EPS flavor (clipboard.importEps). Other files
with those extensions paste as before. A .ai saved without its PDF
part also gets the PDF flavor, and Paste answers it with
clipboard.importPdf's error, as it does for the same file named .pdf.

Docs: docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
bflatastic b9341d3b3b i18n(bg): the PostScript file error names File › Open and File › Place 2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 f3b4c3ec78 PDF import: an axial or radial shading keeps its function's curve when sampled into stops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
bflatastic 136810bee2 PDF/.ai import: the tint transform test passes no ink names to space_of 2026-10-16 03:36:20 +07:00
bflatastic 90e6ff3de1 PDF/.ai import: the tests added on main pass the page's unit to layered_ai 2026-10-16 03:36:20 +07:00
dotCooCoo dc179c3671 .ai import: CMYK images in the editing data keep their ink amounts
When a .ai file, or an EPS with editing data, opened from its editing
data, the reader converted each embedded 8-bit CMYK image to RGB and
stored it as a PNG, so the document lost the image's ink amounts. When
the same .ai opens from its PDF part, the PDF importer keeps them in a
CMYK TIFF.

The reader now stores those samples with ImageBlob::cmyk_tiff, as the
PDF importer does (crates/pdf/src/import_image.rs). An alpha channel
that hides part of the image becomes the image's opacity mask, a
grayscale image over it, as in PDF import. Grayscale, RGB and 1-bit
images still open as RGBA PNGs.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
bflatastic 8af0459232 AI import: a gradient mesh keeps at most 65,536 points, however its patches are cut 2026-10-16 03:36:20 +07:00
EricKuck e93ba4c83f AI import: a gradient in a pattern's definition is placed from the canvas, as the file's PDF part draws it 2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 9ebd30b8bb AI import: a styled object read in place of its drawn look keeps the transparency written on the look
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
dotCooCoo 31ffbb2152 PDF/.ai import: the PostScript file error names File › Open and File › Place, which read the file
document.pdfInfo and clipboard.importPdf, given a PostScript file (an
EPS, or an .ai in an older PostScript format), failed with "this is a
PostScript file (...), which can't be opened yet: save it as PDF,
PDF-compatible .ai or SVG". document.open and file.place read the same
file through the EPS reader. The error told users to convert a file that
VectorCraft opens.

The error now says the file is not a PDF and that File › Open and
File › Place read it as EPS. The 13 catalogs that carry the message get
the new text.
2026-10-16 03:36:20 +07:00
dotCooCoo ca145c5265 PDF/.ai import: spot inks with the same values and different names keep their own swatches
A PDF can define spot inks that have different names and the same
alternate space and tint transform, such as Blue1 and Blue2, both RGB
0 0 255. The interpreter drops the ink name, so the importer matches an
interpreted color to the file's Separation and DeviceN spaces by the
tint transform. The spaces of two such inks matched the same color, so
their paths imported as plain RGB and neither ink became a spot swatch.
This happened with both inks on one page, with each on its own page,
and in the PDF part of a .ai file.

The page walk in import_scan.rs, which follows the interpreter's order
for layers and overprint, now also records the ink of each path, from
the color space set by cs and CS or from the shading of the pattern set
by scn and SCN or drawn by sh, and the ink of the color space of the
text between two paths. When the spaces of several inks match a color,
the importer takes the space with the walk's ink names. It applies
these names to a page only when the page uses such an ink and a
counting pass of the interpreter draws as many paths there as the walk
counted. Otherwise the import works as before.

The file now opens with a spot swatch for each ink. Solid fills and
strokes of paths and type, and the stops of gradients on paths, link
to the swatch of their ink, the points of a gradient mesh keep the
ink's color, and PDF export writes each ink as its own separation.
Paint in these inks inside pattern cells, soft masks and type 3 glyphs,
and gradients on type in these inks, still import as RGB. Type in one
of these inks can also import as RGB when type in another Separation or
DeviceN ink is shown with no path drawn between them.
2026-10-16 03:36:20 +07:00
dotCooCoo 16b8aeb6b7 PDF/.ai import: a .ai whose page has a UserUnit opens with its layers at the page's size
A PDF page's UserUnit (PDF 1.6) sets the size of its user space unit
in multiples of 1/72 inch. The PDF reader has applied it since #1894, so
a page with /UserUnit 10 and a 36 × 50.4 media box opens at 360 × 504
pt. A .ai whose editing data has its artboards in the page's own units
then no longer matched its PDF part. It opened from the PDF part,
without its layers, its hidden objects and the art outside its
artboards, and with its artboards in a row instead of where the
editing data puts them. document.pdfInfo gave such a page's size in
points and its boxes in the file's numbers.

Editing data whose first artboard has the page's size in the page's
units, and not in points, is now compared with the PDF part scaled
down by the UserUnit. The document read from it is then scaled up by
the UserUnit, with its stroke weights, dashes, effects and pattern
tiles. When the layers can't be read, the artboard layout taken from
the editing data is scaled the same way. document.pdfInfo gives the
boxes in points, as it gives the size.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 76888366e7 AI import: a mesh patch's anchors run in the mesh's direction whatever their key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 1682d59b65 AI import: a gradient mesh patch's edges bend through the anchors listed between its corners
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 6545d8b886 AI import: a drawn look that is an image stands for its object, as one of paths does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:19 +07:00
EricKuckandClaude Opus 5.5 fb6fc437c3 AI import: a clipping path alone in a group clips the clipping group around it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:19 +07:00
midasmatterhorn d5a672fda2 eps: an opacity mask in the editing data is named, not drawn whole (#2004) 2026-10-16 03:36:19 +07:00
bflatastic 312a939646 eps: rustfmt the layer transparency change 2026-10-16 03:36:19 +07:00
NetterSilverandClaude Haiku 5.5 dfdb98463f Open PDFs and .ai files with an image over 256 MB decoded
Fixes #2005

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-16 03:36:19 +07:00
midasmatterhorn 6feec01d85 eps: a layer's own transparency in the editing data is read (#2003) 2026-10-16 03:36:19 +07:00
BFlat 2cdd00cf7b Merge pull request #2278 from storytold/fix/properties-flip-labels
Properties: the flip buttons keep the reference app's Properties names (#1910)
2026-10-16 03:23:19 +07:00
BFlat 8958dfe60a Merge pull request #2274 from dotCooCoo/feat/type-reset-tracking
Type: Cmd/Ctrl+Alt+Q resets tracking to 0 and kerning to Auto
2026-10-16 03:22:55 +07:00
BFlat e5c12d0487 Merge pull request #2272 from dotCooCoo/feat/layers-eye-outline-others
Layers: Ctrl/Cmd+Alt-click on an eye switches the other layers between Outline and Preview
2026-10-16 03:22:39 +07:00
BFlat e797b68220 Merge pull request #1920 from sanyamsingh001/codex/r65-width-finite
G1: Fix intermediate overflow in adjoining width adjustments
2026-10-16 03:22:21 +07:00
BFlat f4b0e0f27b Merge pull request #1926 from marcoarnulfo/fix/paste-in-place-off-artboard
Paste in Place: art copied from off the artboards goes back where it was
2026-10-16 03:22:02 +07:00
BFlat 2480017af0 Merge pull request #1939 from dotCooCoo/fix/issue-1692-hex-field-tab
Fix #1692: tabbing through a hex field no longer resets the color
2026-10-16 03:21:46 +07:00
BFlat 312456a769 Merge pull request #1937 from dotCooCoo/fix/issue-1692-tab-order
Part of #1692: Tab in the Color Picker goes through one color model's fields before the next
2026-10-16 03:21:29 +07:00
BFlat bbda88bc71 Merge pull request #1951 from marcoarnulfo/fix/type-color-while-typing
Type: a colour picked while typing keeps what was typed
2026-10-16 03:21:13 +07:00
BFlat a324b0e25f Merge pull request #1967 from switch87/fix/1952-optional-number-strings
Transform commands: a string for an optional number is an error (#1952)
2026-10-16 03:20:58 +07:00
BFlat 39faa02fe5 Merge pull request #1988 from MarekSpodBiedry/issue-1970
Paint: picking another pattern swatch keeps the pattern transform (#1970)
2026-10-16 03:20:40 +07:00
BFlat 57ec0e6d7f Merge pull request #2047 from AyberkA0/i18n-eps-layers-note
i18n: the notes for an EPS or .ai whose layers weren't read are translated (#2035)
2026-10-16 03:20:23 +07:00
BFlat 6341519444 Merge pull request #2048 from AyberkA0/doc-loadprofile-lifetime
color.loadProfile: say that a loaded profile lasts for the run (#1955)
2026-10-16 03:20:07 +07:00
BFlat d4fa50f262 Merge pull request #2146 from AyberkA0/add-anchor-plus
Shortcuts: + selects the Add Anchor Point tool (#2142)
2026-10-16 03:19:50 +07:00
BFlat 6c4804383a Merge pull request #2155 from dotCooCoo/fix/inflate-one-edge-index
3D and Materials: Inflate builds its edge index once
2026-10-16 03:19:34 +07:00
BFlat 263fcff7b1 Merge pull request #2157 from dotCooCoo/fix/export-params-list-ai
Export: the export commands' params and the MCP export tool list the .ai format
2026-10-16 03:19:18 +07:00
BFlat 3c9fda57df Merge pull request #2183 from dotCooCoo/fix/export-for-screens-formats-text
Export for Screens: the params text and the format error list every format a row writes
2026-10-16 03:19:03 +07:00
BFlat 59dfe0834c Merge pull request #2198 from marcoarnulfo/fix/pdf-title-document-name
PDF: an untitled document saved to a file exports titled after it
2026-10-16 03:18:47 +07:00
BFlat b70ba0f82b Merge pull request #2255 from dotCooCoo/docs/print-system-service
Docs: the Print section describes printing through the system's print service
2026-10-16 03:18:31 +07:00
BFlat 3a0d1ce970 Merge pull request #2261 from dotCooCoo/fix/ru-isolate-latin-letter
i18n: Russian Isolate and Sublayer rows use Cyrillic letters only
2026-10-16 03:18:14 +07:00
BFlat 6048fd6b8e Merge pull request #2275 from thejdubb02/fix/cmyk-page-group-isolated
PDF export: a CMYK page without blending through is an isolated group (#2156)
2026-10-16 03:17:57 +07:00
BFlat e479e62317 Merge pull request #2271 from gorkematlyn/upstream-pdf-panic
PDF.R1: never panic on a damaged array or dictionary in an imported PDF
2026-10-16 03:17:40 +07:00
BFlat 8f1d730ccb Merge pull request #2214 from dotCooCoo/feat/outline-object-type-art
Type: type drawn as glyph outlines keeps its underline, stroke weight, gradients and patterns
2026-10-16 03:17:22 +07:00
BFlat 043f2dff3c Merge pull request #2215 from dotCooCoo/fix/export-selection-keeps-effects
Export Selection: the crop takes in what each object's effects draw
2026-10-16 03:17:05 +07:00
BFlat b8d1bdea40 Merge pull request #2219 from dotCooCoo/feat/print-fallback-font-warning
Print: Print and Print Preview warn when type prints in the fallback font
2026-10-16 03:16:49 +07:00
BFlat 8ca5615dca Merge pull request #2242 from dotCooCoo/fix/data-set-sets-objects-in-groups
Variables: selecting a data set sets the objects inside a group that it shows
2026-10-16 03:16:35 +07:00
BFlat 0a0d22df57 Merge pull request #2250 from dotCooCoo/fix/variable-binding-errors
Variables: binding errors name the command and say when a layer is hidden
2026-10-16 03:16:19 +07:00
BFlat f3eab99283 Merge pull request #2226 from dotCooCoo/feat/update-data-set-keeps-own-values
Variables: Update Data Set writes only the variables the active data set holds
2026-10-16 03:16:03 +07:00
BFlat f253d2b3e3 Merge pull request #2229 from dotCooCoo/fix/pen-cursor-asterisk
Pen: the new-path asterisk leaves the pointer once a path is started
2026-10-16 03:15:47 +07:00
BFlat 70723a799b Merge pull request #2234 from dotCooCoo/feat/proof-setup-tritanopia
View: Proof Setup simulates tritanopia
2026-10-16 03:15:32 +07:00
BFlat 9bbe74bd19 Merge pull request #2236 from dotCooCoo/feat/transform-alt-enter-copy
Transform: Alt+Enter applies a value to a copy of the selection
2026-10-16 03:15:17 +07:00
BFlat f3363bddc2 Merge pull request #2246 from dotCooCoo/feat/pencil-keep-selected
Pencil and Paintbrush: with Keep selected off, a drawn path is not left selected
2026-10-16 03:14:56 +07:00
BFlat 80f084d8cf Merge pull request #2247 from dotCooCoo/feat/new-swatch-global-process
Swatches: Ctrl/Cmd+Shift-click on New Swatch makes a global process color
2026-10-16 03:14:41 +07:00
BFlat f614428a92 Merge pull request #2252 from dotCooCoo/feat/drop-shadow-darkness
Effects: Drop Shadow has Darkness, the object's own colors with black added
2026-10-16 03:14:23 +07:00
BFlat 7f899ef293 Merge pull request #2254 from dotCooCoo/feat/shift-tab-panels
Window: Shift+Tab shows and hides the panels but leaves the toolbar and the Control bar
2026-10-16 03:14:03 +07:00
BFlat e3e290d43b Merge pull request #2256 from dotCooCoo/feat/draw-inside-double-click
Draw Inside: a double-click outside the object returns to Draw Normal
2026-10-16 03:13:41 +07:00
BFlat b069019cdd Merge pull request #2257 from dotCooCoo/feat/sample-color-from-screen
Color: Sample Color from Screen picks a color anywhere on the screen
2026-10-16 03:13:25 +07:00
BFlat e55122afc5 Merge pull request #2258 from dotCooCoo/feat/control-bar-isolation
Control bar: buttons isolate the selected object and exit isolation mode
2026-10-16 03:13:08 +07:00
bflatastic 73f3731dbf Properties: the flip buttons keep the reference app's Properties names (#1910)
#2227 renamed them Flip Horizontal and Flip Vertical; what they do is unchanged. The reference app's Properties panel calls them Flip Along Horizontal Axis and Flip Along Vertical Axis (the Transform panel's menu keeps Flip Horizontal and Flip Vertical), so the labels and their catalog rows come back.
2026-10-16 02:56:55 +07:00
bflatastic 5e374712dc i18n(bg): Reset Tracking and Kerning 2026-10-16 02:56:55 +07:00
dotCooCoo d726f6596c Layers: Ctrl/Cmd+Alt-click on an eye switches the other layers between Outline and Preview
A Ctrl/Cmd+Alt-click on a row's eye in the Layers panel ran Hide Others
or Show All Layers, as an Alt-click does. It now runs Outline Others for
the row's layer (layer.outlineOthers), or Preview All Layers
(layer.previewAll) when every other top-level layer is in Outline
already. Each is one undo step. Alt-click and Ctrl/Cmd-click on an eye
keep their actions.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:56:35 +07:00
sanyamsingh001andCodex 98511401a0 G1: Fix intermediate overflow in adjoining width adjustments
Co-authored-by: Codex <[email protected]>
2026-10-16 02:56:35 +07:00
Marco Arnulfo f2f1945973 Paste in Place: art copied from off the artboards goes back where it was (fixes #1924) 2026-10-16 02:56:35 +07:00
dotCooCoo d35ed26b0f Fix #1692: tabbing through a hex field no longer resets the color
In the Color Picker, Tab out of an edited field gives the next field the
focus in the same frame, and the picker applies the edit once it has
drawn its fields. The # field after Y took the focus showing the color
from before the edit and kept that text while it had the focus, so the
next Tab committed it and reset every other field. Shift+Tab into the #
field from K had the same effect. Swatch Options and New Swatch draw a
copy of the field, and Tab from the last component field into it had the
same effect there.

widgets::hex_field and the Swatch Options copy now show the current
color until something is typed into them, also while they have the
focus, and commit only what was typed. The change covers the # fields of
the Color Picker, Swatch Options, New Swatch, the Color panel, Color
Themes, Recolor Artwork, Document Setup and Define Perspective Grid.

widgets::opt_field, which draws the Stroke panel's dash and gap fields,
kept its text the same way: passing through a blank gap after typing a
dash committed the blank, and the panel applied the same dash pattern
again. It gets the same change. When its value changes while it has the
focus and nothing has been typed into it, all of the new value is
selected, as on keyboard focus.
2026-10-16 02:56:35 +07:00
dotCooCoo aaa7359c7b Part of #1692: Tab in the Color Picker goes through one color model's fields before the next
The Color Picker drew its value fields as one grid, row by row. Tab
moves through the fields in the order they are drawn, so it went H, L,
S, a, B, b, R, C, G, M, B, Y, # and K, from one color model to the
other on every row.

HSB, RGB and the hex field are now in one grid, and Lab and CMYK are in
a second grid beside it. Tab goes through H, S, B, R, G, B and #, then
L, a, b, C, M, Y and K. The dialog looks as before: K's row is as tall
as the hex field's row beside it.
2026-10-16 02:56:35 +07:00
Marco Arnulfo 7608639021 Type: a colour picked while typing keeps what was typed (fixes #1950)
The Color panel and the Color Picker preview a colour in an interaction.
While typing, that interaction joined the open typing session and
previewed from the document before the typing, so new type came back as
its placeholder text. Typing in progress is now kept as its own step
before another interaction begins, as Undo already does.
2026-10-16 02:56:35 +07:00
Gert Pellin e6170de1a2 Transform commands: a string for an optional number is an error (#1952)
object.setBounds read x, y, width and height with f64_or, which falls back
to the current value for anything that isn't a JSON number, so
{"width": "60 mm"} returned ok and changed nothing. object.move,
object.nudge, object.rotate, object.scale (sy) and object.shear did the
same with their optional numbers. A value that is neither a number nor
null is now a BadParams error naming the key, as a missing required
number already is.
2026-10-16 02:56:35 +07:00
MarekSpodBiedry a0f8a88e82 Paint: picking another pattern swatch keeps the pattern transform (#1970) 2026-10-16 02:56:35 +07:00
Ayberk A. 6d898558fd i18n: the notes for an EPS or .ai whose layers weren't read are translated (#2035)
The importer wraps its reason in "the file's layers weren't read ({why}):
it comes in as its page, in one layer" (and, for an .ai, "... from its
editing data ({why}): they come from its PDF part"). The reasons had @msg
rows, the wrappers none, so after "Opened with 1 note(s):" the whole note
stayed English. The two templates are now in every language that
translates messages (and pt-br, which has the reasons), and the reason
inside is translated through them. These notes are pushed as warnings, so
the message scan doesn't see them: a test checks them in each language.

"<Clip Group>" already has a French entry on main; layer names such as
"Layer 1" are stored names and stay as they are.
2026-10-16 02:56:35 +07:00
Ayberk A. 5ff34782cb color.loadProfile: say that a loaded profile lasts for the run (#1955)
The profile is registered in the process's colour registry only, so the
next `vectorcraft-cli run` (or the next launch of the app) doesn't know it
and edit.colorSettings falls back to the built-in profile. The command's
description now says so, and that the profile has to be loaded again in
each run that names it. Keeping profiles across runs would need a place in
the preferences to remember them, which is left for the maintainers.
2026-10-16 02:56:35 +07:00
Ayberk A. 07c6b938bc Shortcuts: + selects the Add Anchor Point tool (#2142)
The tool's default key is "+", and `shortcuts::parse` split a bare "+" as a
separator with nothing on either side: the key part came out empty, so the
shortcut didn't parse, `tool_for_key` found no tool for a typed "+" and the
key did nothing. "-" (Delete Anchor Point) has no separator to trip on, which
is why it worked. A bare "+" is now the key itself, the "=" key, as "Cmd++"
already was, so "+" typed with Shift on a US layout, by a layout's own "+"
key or on the numpad selects the tool.
2026-10-16 02:56:35 +07:00
dotCooCoo cb9522f7cd Type: Cmd/Ctrl+Alt+Q resets tracking to 0 and kerning to Auto
The reference app resets tracking and kerning to 0 with Cmd/Ctrl+Alt+Q.
VectorCraft had no command for it.

type.resetTracking (Reset Tracking and Kerning, default Cmd+Alt+Q) sets
tracking to 0 and kerning to Auto, which the Character panel shows as 0,
in the text the Type tool has selected, or else in all the text of
every selected type object. At a caret it resets only the kerning of
the character before it, where Alt+Left/Right kerns. It is one undo
step. The range logic of type.step moves into a shared type_ranges.

When a call gives ids, type.step and type.resetTracking change all the
text of those type objects even while the Type tool edits text, as
paint.setFill does. Before, type.step changed the Type tool's selected
text instead.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:56:35 +07:00
dotCooCoo 0155eaf468 3D and Materials: Inflate builds its edge index once
inflate built an edge index (Edges) of the shape's rings, and
Membrane::solve, which inflate calls first, built a second index of the
same rings. Membrane::solve now takes the index that inflate builds.

In a release build, one Edges::new takes 0.08 ms of the 27 ms that
inflate takes on a ring of 2,000 points, and 2.0 ms of 219 ms on a ring
of 1,000 spikes whose 2,000 edges fill 693,816 bucket entries. The
facets do not change: for nine shapes, three views and 96 combinations
of the Inflate settings, the Debug text of all 2,592 results is byte
for byte the same on main and with the change.
2026-10-16 02:56:34 +07:00
dotCooCoo 5a83bcfafa Export: the export commands' params and the MCP export tool list the .ai format
document.export, document.serialize and document.exportSelection write
a PDF-compatible .ai (format: "ai"), and document.formats lists ai as
writable, but none of their params texts named it. The params text of
document.exportSelection named only png, jpg, webp, svg, svgz and pdf,
although the command writes all 20 writable formats. The description
of the MCP export tool and its row in docs/mcp.md named every format
of the tool's format enum except ai, and the range text and the row
said that only PDF writes several artboards.

The three params texts now list every format their command writes,
and document.export's range and useArtboards texts name .ai beside
PDF. The MCP export tool's description names ai, and its artboard and
range texts say that .ai, like PDF, writes one page per artboard. The
range text and the row also say that SVG, SVGZ and EPS write one file
per chosen artboard, and that rasters, DXF, EMF and WMF do so with
options.useArtboards: true.

Docs: docs/mcp.md (the export tool).
2026-10-16 02:56:34 +07:00
dotCooCoo 66dbe40ea6 Export for Screens: the params text and the format error list every format a row writes
The document.exportForScreens params text listed png, png8, jpg, webp,
gif, svg, svgz and pdf for a row's format, and its settings keys left
out svgz. A row of another format VectorCraft writes, such as eps, was
refused with "Export for Screens writes png, png8, jpg, webp, gif, svg,
svgz or pdf". Rows of tiff, bmp, tga and psd are written too, and the
Export for Screens dialog offers TIFF rows.

The params text, its settings keys and the error now list the 12
formats a row writes: png, png8, jpg, webp, gif, tiff, bmp, tga, psd,
svg, svgz and pdf. Which formats a row accepts does not change.
2026-10-16 02:56:34 +07:00
Marco Arnulfo 53c59f06de PDF: an untitled document saved to a file exports titled after it (fixes #2166) 2026-10-16 02:56:34 +07:00
dotCooCoo 513685a221 Docs: the Print section describes printing through the system's print service
The Print section of docs/mcp.md said that File → Print writes the job
as a print-ready PDF and that there is no printer driver. The desktop
app sends the PDF to the system's print service, the web build opens
the browser's print dialog on it, and the PDF File entry in the
Printer list saves it. The section now says so, points to Printing,
and marks the `file.print` reply that follows as the headless one.
2026-10-16 02:56:34 +07:00
dotCooCoo 4a1323ad07 i18n: Russian Isolate and Sublayer rows use Cyrillic letters only
The Russian catalog's Isolate row began with a Latin "A" (U+0041),
"Aизолировать", and the Create New Sublayer and New Sublayer… rows
spelled "подслой" with a Latin "c" (U+0063). The rows now read
"Изолировать", "Создать новый подслой" and "Новый подслой…".

A new test fails when a word in any catalog's translation holds letters
from more than one of the Latin, Cyrillic and Greek scripts.
2026-10-16 02:56:34 +07:00
Justin Willhite 8da47f11f8 PDF export: a CMYK page without blending through is an isolated group (#2156) 2026-10-16 02:56:34 +07:00
bflatastic 18f8a1bdcf PDF import: a damaged array is refused as damaged, not as functions built on one another 2026-10-16 02:56:34 +07:00
Claude 95d27fe390 PDF.R1: never panic on a damaged array or dictionary in an imported PDF
hayro-syntax's Dict::entries still unwraps each value it reads, so a
dictionary cut short (or with a stray token) aborts the import. The
craft-libs ArrayIter now stops at the first unreadable item instead of
panicking, which also means a hostile item after a self-referencing
function or colour space is silently dropped from nests_without_end.

- import_color: `readable(array)` checks every item parses; `items()`
  yields nothing for an unreadable array; `entries()` reads a dictionary
  key by key through get_raw, skipping values that don't parse.
- pages/editing/import_scan: use the helpers instead of raw_iter() and
  Dict::entries().
- nests_without_end refuses a file with an unreadable array.
- Tests: two refusal cases in pdf tests and an .ai cut inside an array
  or dictionary in engine/tests/import_fuzz.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KcHisW1vt6pkVPYbzL6Cpg
2026-10-16 02:56:34 +07:00
bflatastic ff7f47119d i18n(bg): rows for the strings this batch adds (Darkness, Isolate Selected Object, Keep selected, Sample Color from Screen, Tritanopia, Panels Except Tools and Control) 2026-10-16 02:56:32 +07:00
BFlat 01c66e51b7 Merge pull request #2016 from EricKuck/fix/ai-pattern-phase
AI import: a pattern fill tiles from its tile's lower left, wherever the definition drew the tile
2026-10-16 02:38:08 +07:00
BFlat 23a0a5f857 Merge pull request #2015 from EricKuck/fix/ai-non-native-art-orientation
AI import: non-native art stays upright unless its placement mirrors it
2026-10-16 02:37:52 +07:00
BFlat 651e2e1a57 Merge pull request #2014 from EricKuck/fix/export-art-over-artboard-groups
SVG export: a chosen artboard's file leaves out the art of other artboards inside a shared group
2026-10-16 02:37:38 +07:00
BFlat 5a5bb73aa9 Merge pull request #2062 from dotCooCoo/fix/page-knockout-group-backdrop
Render: a knockout group drawn over its backdrop counts an element's partial coverage once
2026-10-16 02:37:22 +07:00
BFlat 88e950a0d7 Merge pull request #2073 from dotCooCoo/feat/outline-object-nested-type
Effects: type in a group with a geometry effect keeps its transparency, mask, effects and visibility
2026-10-16 02:37:05 +07:00
BFlat 99bdcc6633 Merge pull request #2232 from MarekSpodBiedry/issue-2086
Menus: keep scroll bar clear of shortcuts and stop above the status bar (#2086)
2026-10-16 02:36:44 +07:00
BFlat ef53890c28 Merge pull request #2109 from dotCooCoo/fix/area-type-rows-columns-overflow
Type: area type opens and lays out with at most 100 rows and 100 columns
2026-10-16 02:36:23 +07:00
BFlat 011a1a0314 Merge pull request #2108 from dotCooCoo/fix/issue-1770-rosette-spurs
Fix #1770: Shape Builder merges leave no spur or split outline when shared edges differ by rounding
2026-10-16 02:36:05 +07:00
BFlat 17c512f5c9 Merge pull request #2055 from dotCooCoo/fix/copied-artboard-free-id
Artboards: copied artboards take a free id and keep their own guides
2026-10-16 02:35:47 +07:00
BFlat ec602d7e27 Merge pull request #2114 from dotCooCoo/fix/moved-rasters-test-fonts-steady
Render tests: two moved raster tests no longer run alongside the tests that add fonts
2026-10-16 02:35:30 +07:00
BFlat bb5f16c101 Merge pull request #2118 from dotCooCoo/fix/alt-drag-tests-created-stamp
Tests: the Alt-drag tests no longer fail when their documents are made in different seconds
2026-10-16 02:35:13 +07:00
BFlat b8041d3745 Merge pull request #2235 from Messhias/fix/2094-create-outlines-shortcut
Fix #2094: Cmd+Shift+O Create Outlines while typing
2026-10-16 02:34:55 +07:00
BFlat 0205a3332f Merge pull request #2227 from Messhias/fix/1910-reflect-axis
fix(G11): Reflect X/Y flip the correct axes (#1910)
2026-10-16 02:34:42 +07:00
BFlat a7425f27c1 Merge pull request #2176 from Silvertree2010/fix/2078-when-i-click-pixels-the-unit-of
Make New Document preset cards follow the chosen units
2026-10-16 02:34:25 +07:00
BFlat fb803dbdae Merge pull request #2158 from Silvertree2010/fix/2084-the-orientation-selection-is-wro
Fix New Document orientation on category tabs and preset cards
2026-10-16 02:34:08 +07:00
BFlat 81420b87c8 Merge pull request #2204 from Silvertree2010/fix/h1429-svg-export-writes-control-charac
Make SVG export of type with control characters well-formed
2026-10-16 02:33:51 +07:00
BFlat 57b5931c83 Merge pull request #2022 from Silvertree2010/fix/h797-negative-picas-points-value-2p6
Apply the sign of a picas+points value to the whole value
2026-10-16 02:33:32 +07:00
BFlat ce02a42f35 Merge pull request #2069 from Silvertree2010/fix/h839-field-arithmetic-rejects-followe
Evaluate * and / left to right in field arithmetic
2026-10-16 02:33:15 +07:00
BFlat 4b60fcead7 Merge pull request #2209 from Silvertree2010/fix/h1425-field-arithmetic-rejects-a-negat
Accept a negative operand after an operator in field arithmetic
2026-10-16 02:32:59 +07:00
dotCooCoo 3bef84aaa6 Type: type drawn as glyph outlines keeps its underline, stroke weight, gradients and patterns
Type is drawn as its glyph outlines under a geometry effect, in EMF and
WMF export, in DXF export when it is written as outlines, through
Flatten Transparency with Convert All Text to Outlines, and inside an
envelope. Those outlines differed from the type the canvas draws in
four ways. They had no underline or strikethrough bars. A character
stroke on scaled type kept its unscaled weight. An unplaced gradient was
fitted to the glyphs it painted instead of the layout box. A run's
pattern tiles stayed in document space instead of scaling and turning
with the type. A group's or layer's own fills and strokes also painted
the glyphs of the type in it without the bars.

The outlines now include each run's underline and strikethrough bars,
as the canvas has drawn them since #850. Each run's path is made in
text space with its unplaced gradients fixed to the layout box, then
placed by the type's transform, which also scales its stroke weight
and dashes and transforms its pattern tiles. The type's own fills and
strokes have their unplaced gradients fixed to the bounding box of the
layout box in the document, where the canvas fits them.

Geometry effects that move or scale the outlines move and scale the
type's gradients with them: the type's own effects, also inside an
envelope and after Flatten Transparency has converted the type to
outlines, and the effects of a group, symbol instance or envelope that
holds the type. outline_text marks its group with a new node field,
type_outlines, which is saved with the document. reshape_leaves maps
the placed gradients in each marked group through the map that takes
the group's bounds before the effects onto its bounds after them. A
scale out of proportion stretches a radial gradient into an ellipse
and turns the isolines of a diagonal linear gradient, as it does on
type scaled by its own transform. Placed gradients outside those
groups stay where they are.
2026-10-16 02:26:15 +07:00
dotCooCoo 98d1d738a2 Export Selection: the crop takes in what each object's effects draw
Export Selection, Asset Export, the Assets tab of Export for Screens
and the rasterized art of CSS Properties cropped each object to its
visual bounds, which leave out live effects. A path with an 8 pt drop
shadow was exported with the shadow cut off at the path's edge, and
a group's own shadow was cut off the same way.

The crop is now the bounds the renderer measures for what it draws
(`vectorcraft_render::effect_bounds`): `painted_bounds` without its
room for brush art. A brushed path is cropped to its visual bounds as
before, and a Shaper group leaves out its transparent source art as
its visual bounds do. `assets.list` reports the same crop. The
renderer measures a shadow's or glow's reach on every side of the
object, so the image also has transparent room on the side away from
an offset shadow.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:26:14 +07:00
dotCooCoo 1c30cbf88c Print: Print and Print Preview warn when type prints in the fallback font
PDF, EPS, EMF/WMF and raster exports, and SVG exports that outline or
embed fonts, warn when type is set in a font family that isn't
available, but file.print and print.preview gave no such warning. A job
in a missing family printed in Source Sans 3 with no warning, and the
Print dialog's Summary showed none.

Both commands now add the exports' warning for the type that prints:
visible type that fits its frame or path, on the layers that Print
Layers picks, where what it paints (its effects applied) lands on the
paper of an artboard or tile that prints, inside its clipping paths.
The type in the symbols, inline graphics, patterns, scatter brushes,
opacity masks and placed documents of the art that prints counts too.
file.print reads the fonts before raster effects and Print as Bitmap
turn type into images, for PDF and PostScript output.

Docs: docs/mcp.md (Print), docs/target-app-parity.md (Fonts).
2026-10-16 02:26:13 +07:00
dotCooCoo d069f0fe98 Variables: selecting a data set sets the objects inside a group that it shows
dataset.select applied a row's values in the order the variables were
defined, and it skips an object inside a hidden group. When a row
showed a group and set an object inside it, and the object's variable
was defined first, the object was skipped while the group was still
hidden: one selection applied the row in part, and selecting the row
again applied the rest. A text variable on type inside the group was
skipped the same way, and a row that hid a group skipped the objects
inside it whose variables were defined after the group's.

dataset.select, dataset.next and dataset.prev now apply the visibility
values that show objects first, outermost object first, then the other
values in definition order, then the visibility values that hide
objects, innermost object first. Of two variables of the same kind on
one object, the one defined last still wins.

Docs: docs/mcp.md.
2026-10-16 02:26:11 +07:00
dotCooCoo 01b3599227 Variables: binding errors name the command and say when a layer is hidden
Binding art in a hidden group or layer failed with "node N is locked",
even when nothing was locked. The error now says whether the object is
locked, is in a locked group or layer, or is in a hidden group or layer.

Make Text Dynamic and Make Visibility Dynamic reported their errors under
`variable.makeDynamic`, which is not a command id. They now name
`variable.makeTextDynamic` or `variable.makeVisibilityDynamic`.
2026-10-16 02:26:09 +07:00
dotCooCoo 00b02c1bdb Variables: Update Data Set writes only the variables the active data set holds
A data set can hold values for only some variables: `dataset.new` and
`dataset.set` store only the values they are given, and selecting the
data set leaves the other variables alone. Update Data Set replaced the
active data set's values with the values the art reports for every
variable. After one Update, a data set that set only a visibility
variable also held the current text of the bound type, and selecting it
again wrote that text back. Update also dropped a variable's value when
its objects were locked or inside a hidden or locked layer or group,
because such objects report no value.

`dataset.update` now writes the art's current value for each variable
the active data set holds, and keeps the data set's value when the art
reports none. A variable that the data set does not hold stays out of
it, including one defined after the data set was captured;
`dataset.update {all: true}` also adds the other variables that the art
reports a value for. The reply adds `left`, the variables it did not
write. The Variables panel's Update Data Set button and menu item run
it without `all`.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:26:08 +07:00
dotCooCoo ff33e0556f Pen: the new-path asterisk leaves the pointer once a path is started
The Pen's pointer showed the new-path asterisk before the first anchor
and also while a path was being drawn, where a click adds the path's
next anchor. A new Cursor::PenDrawing, the pen nib without a badge, now
shows there. The close, convert and join badges still show over the
first anchor, the last anchor and another open path's end. The asterisk
shows only where a click starts a new path.

The Continue, Add and Delete badges now also show after another path
was selected while the Pen was still drawing (for example with a
Cmd-click). A click there already continued the path or added or
deleted an anchor, but the pointer showed the asterisk.
2026-10-16 02:26:06 +07:00
dotCooCoo 35f43d631f View: Proof Setup simulates tritanopia
View › Proof Setup had two color blindness items, protanopia and
deuteranopia. A third item, Color blindness – Tritanopia-type, proofs
the canvas with a tritanopia simulation, and the Separations Preview
panel's Proof Setup list offers it too. `view.proofSetup` takes
`target: "tritanopia"`.

The simulation follows Brettel, Viénot and Mollon (1997): a color
keeps its L and M cone responses, and its S response is replaced so
that it lies on one of two half-planes. Both contain the neutral
axis; one passes through the 485 nm color and the other through the
660 nm color. The neutral axis runs to the display's white, so grays
keep their value. The matrices are computed at
compile time from the published inputs cited in the code. The
protanopia and deuteranopia matrices are unchanged.
2026-10-16 02:26:04 +07:00
dotCooCoo bbad808a02 Transform: Alt+Enter applies a value to a copy of the selection
In the Transform panel, the Properties panel's Transform section and
the Control bar, Alt+Enter (Option+Return) in X, Y, W, H, Rotate or
Shear ended editing as Enter does, and the selection itself moved,
resized, turned or sheared. Now the field's command gets `copy: true`
when the Enter that committed it was pressed with Alt. The copy takes
the value, the original stays where it was, and the copy is selected,
in one undo step. A press elsewhere with Alt held applies the value
without a copy. With anchor points selected with Direct Selection,
Alt+Enter applies the value to them, as Enter does.

- widgets::enter_modifiers returns the modifiers of the frame's Enter
  press.
- transform::alt_enter_copies gives the `copy` param of the three
  surfaces' commands.

Docs: docs/target-app-parity.md.
2026-10-16 02:26:01 +07:00
dotCooCoo d68e14a393 Pencil and Paintbrush: with Keep selected off, a drawn path is not left selected
A Pencil or Paintbrush stroke always left the path it drew or continued
selected. The reference app's Pencil and Paintbrush options have Keep
selected for this, and VectorCraft's Tool Options had no such row.

The Pencil and Paintbrush Tool Options now have a Keep selected
checkbox, on by default, which keeps today's behavior. When it is off,
a new path is not selected and the selection stays as it was, and a
continued path is removed from the selection while the rest stays. The
stroke is still one undo step. The option persists like the tools'
other options, and path.freehand and brush.freehand take keepSelected.

Docs: docs/target-app-parity.md, docs/ui-parity.md, docs/control-protocol.md.
2026-10-16 02:26:00 +07:00
dotCooCoo 4d63227223 Swatches: Ctrl/Cmd+Shift-click on New Swatch makes a global process color
Ctrl/Cmd+Shift-clicking New Swatch in the Swatches panel opened New
Swatch for a spot color, as a Ctrl/Cmd-click does. It now opens the
dialog for a global process color, and with Alt also held it saves one
at once. `ui.newSwatch` takes `global`.

An Alt+Ctrl/Cmd-click with a gradient or pattern as the active paint
failed with "spot, mode and group apply to solid colours only". It now
saves a gradient or pattern swatch, as a Ctrl/Cmd-click does through
the dialog.
2026-10-16 02:25:59 +07:00
dotCooCoo 720ce5ed73 Effects: Drop Shadow has Darkness, the object's own colors with black added
The reference app's Drop Shadow has a Color and a Darkness, the
percentage of black added to the object's own colors. Drop Shadow had
only a Color, so every shadow was one color. Choosing one or the other,
and the defaults, are VectorCraft's own.

Drop Shadow now takes `shade` ("color" or "darkness", default "color")
and `darkness` (%, default 100). Its dialog and the Appearance panel's
inline editor show Color and Darkness as radio buttons. With Darkness
d, a CMYK document keeps the object's cyan, magenta and yellow and
raises its black from K to K + d × (1 − K), and an RGB document
multiplies each channel by 1 − d, so in RGB 0% gives the object's
color and 100% gives black. A shadow saved without these keys draws as
before. SVG export writes a Darkness shadow with feComponentTransfer
and SVG import reads it back; CSS export leaves it out.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:58 +07:00
dotCooCoo b5a0fee1ea Window: Shift+Tab shows and hides the panels but leaves the toolbar and the Control bar
Shift+Tab ran Tab's command and hid the toolbar and the Control bar
along with the panels. egui matches a chord without Shift when Shift
is also held.

A new UI command, window.panels (default Shift+Tab), shows and hides
the dock and the floating panels and leaves the toolbar and the
Control bar as they are. The shortcut table tries chords with more
modifiers first, so Tab's chord no longer takes Shift+Tab. Hiding the
panels with Tab or Shift+Tab also closes a panel popped out of the
icon column, which stayed on screen at the window's right edge.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:56 +07:00
dotCooCoo 103c6ebdfb Draw Inside: a double-click outside the object returns to Draw Normal
In Draw Inside, a double-click with the Selection, Direct Selection or
Group Selection tool outside the object being drawn into now sets the
drawing mode to Draw Normal (view.drawMode {mode: "normal"}), as
Shift+D does. The object's geometric bounds, grown by the selection
tolerance, count as inside, and a double-click after the object was
deleted also returns to Draw Normal. The tool then takes the
double-click as before: with the Selection tool, a double-click on the
empty canvas still exits isolation mode, and one on another object
still isolates that object. Other tools leave Draw Inside on.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:55 +07:00
dotCooCoo 25df070576 Color: Sample Color from Screen picks a color anywhere on the screen
The Color Picker gets an eyedropper button beside the New and Original
chips, and `ui.sampleScreenColor {stroke?, picker?}` shows the system's
color sampler. The command answers at once. When the person clicks a
color anywhere on the screen, the color becomes the fill or the stroke
through paint.setFill / paint.setStroke as one undo step, or with
`picker` it becomes the open Color Picker's color, which previews on the
selection as any pick does. Escape cancels the sampler. The sampled sRGB
color goes through Cms::srgb_to_rgb, so in a Display P3 working space a
color inside sRGB shows as it did on the screen. `ui.inspect` reports
`screenSampler` while the sampler is open.

- macOS uses NSColorSampler (apps/vectorcraft/src/mac_screen_color.rs).
- The web app uses the browser's EyeDropper API where the browser has
  one.
- Without a sampler (Windows, Linux, other browsers) the button is gray
  and the command returns an error. Keyboard Shortcuts lists the
  command, so it can be given a key.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 02:25:53 +07:00
dotCooCoo a14abc3177 Control bar: buttons isolate the selected object and exit isolation mode
The Control bar had no isolation controls. In isolation mode it now
shows an Exit Isolation Mode button (`object.exitIsolation`), except
while a pattern is edited: the isolation bar's Done and Cancel end
that edit. Outside isolation mode, with one group, compound path,
path, image, mesh or placed file selected, it shows an Isolate
Selected Object button (`object.isolate`). After a Direct Selection
click on a path inside a group, the button isolates that path. An
envelope's contents get no button: for them the Control bar shows the
envelope's Edit Envelope and Edit Contents buttons.

The button offers the kinds of object that the Selection tool's
double-click isolates. The double-click's check moves unchanged into
`vectorcraft_tools::select::isolates`, and both use it.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:49 +07:00
EricKuckandClaude Opus 5.5 a3081b9f53 AI import: a pattern fill tiles from its tile's lower left, wherever the definition drew the tile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:47 +07:00
EricKuckandClaude Opus 5.5 710b6058ca AI import: non-native art stays upright unless its placement mirrors it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:47 +07:00
EricKuckandClaude Opus 5.5 0a4e9ea8c8 SVG export: a chosen artboard's file leaves out the art of other artboards inside a shared group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:47 +07:00
dotCooCoo e22b6a5d45 Render: a knockout group drawn over its backdrop counts an element's partial coverage once
A knockout group that is not isolated, and whose elements blend, is
drawn over a copy of the art below it when the renderer has no layer
open around it. Each element was drawn over the whole copy in a layer,
and the layer was cut to the element's knockout shape with DestIn.
Where the shape covers part of a pixel, the cut counted the element's
coverage a second time. With Page Knockout Group on and Page Isolated
Blending off, the page is drawn this way as soon as an object on it
outside an isolated group has a blend mode other than Normal, on itself
or on a fill or stroke. On a test page, adding a Multiply square
changed the half-covered edge of a blue square over orange from
[115, 115, 141] to [172, 159, 147] on white.

Each element is now drawn over the copy offscreen, over its own
bounds. With the DestOut pass before it, each pixel gets (1 - shape)
times what was below plus shape times the element composited with the
copy at a shape of 1, as ISO 32000-1 section 11.4.6 describes. Pixels
that the shape covers fully or not at all are drawn as before.
2026-10-16 02:21:47 +07:00
dotCooCoo 5473e3a5c5 Effects: type in a group with a geometry effect keeps its transparency, mask, effects and visibility
Geometry effects on a group or layer, and effects on a symbol instance,
blend or repeat, outline the type inside it. outline_nested_text
replaced each type object with a plain group of its outlines, so the
type lost its opacity, blend mode, opacity mask, knockout shape
option, own effects and perspective, and hidden type was drawn,
exported and made visible by Expand Appearance.

outline_nested_text now replaces type with the art that as_art makes
for it, which is also what reshape makes for type with geometry
effects of its own. That art has the type's own geometry effects and
perspective applied and keeps its name, transparency, opacity mask
and other effects. as_art also keeps the object's visibility and
knockout shape option, so hidden type with a geometry effect of its
own is left out of exports. Type in a clip group's clipping path still
becomes its plain outlines and now keeps its visibility.
2026-10-16 02:21:47 +07:00
MarekSpodBiedry 8e58b9a23f Menus: keep scroll bar clear of shortcuts and stop above the status bar (#2086) 2026-10-16 02:21:47 +07:00
dotCooCoo 0c3a5929c7 Type: area type opens and lays out with at most 100 rows and 100 columns
In a dev build, opening a native file whose area type has 2^60 rows
or 2^60 columns failed with an internal error ("capacity overflow"),
and opening one with 2^32 rows and 2^32 columns failed with "attempt
to multiply with overflow". Text layout reserved room for rows times
columns frame cells. `text.areaOptions` keeps both counts within 1 to
100, but reading a document kept the counts the file gave.

Reading a document now clamps each count to 1 to 100
(`AreaOptions::MAX_ROWS_COLUMNS`): a larger count reads as 100, and 0
reads as 1, as layout already treated it. Text layout clamps both
counts the same way. Area type with 1 to 100 rows and columns lays out
as before.

The cells of a frame now share one copy of the frame's flattened
polygons and one copy of the flattened text wrap shapes. Each cell held
its own copy: in a dev build, a test process that laid out text in 100
rows and 100 columns of a circle frame of radius 1,000,000 pt peaked at
1.33 GB of memory, and now peaks at 100 MB.
2026-10-16 02:21:47 +07:00
dotCooCoo 7960fa722f Fix #1770: Shape Builder merges leave no spur or split outline when shared edges differ by rounding
The Shape Builder merges faces by uniting their contours, and the edges
two faces share cancel out. The sweep can give two faces copies of a
shared edge whose ends lie a few times its tolerance apart, with the
difference left as pieces shorter than the tolerance. The union could
then keep the space between the copies: a hairline that joined a hole
as a spur, or a seam that cut a petal tip off the ring's outer outline
as an outline of its own. On Apple Silicon, turning one petal of the
rosette test by -180 degrees instead of 180 leaves a spur.

A Shape Builder action unites the picked faces first, and then what is
left of each shape. When two copies of a shared edge differ in the
first of these unions that unites the faces' contours, each point of
the faces' contours that lies within 16 times the sweep's tolerance of
a point kept before it now moves onto that point before that union and
each later one, with the handles next to it. A piece left within that
distance of one point is dropped. The planar map joins the ends of
copies of an edge within the same distance. Copies that share their
ends leave at most a hairline contour, which the merge drops as a
sliver. When the copies in that union all match exactly, the unions
take the contours unchanged. Only the Shape Builder's merges and
deletions unite faces this way, so Pathfinder output does not change.
2026-10-16 02:21:46 +07:00
dotCooCoo ac4d69032c Artboards: copied artboards take a free id and keep their own guides
Duplicate Artboards, the Artboard tool's Alt-drag (artboard.move with
copy) and Paste of a copied artboard gave the new artboard the largest
artboard id plus one, saturating. In a document with an artboard id
of 4,294,967,295 (u32::MAX), which only a hand-made file has, the copy
got that same id. Artboard guides record the id of their artboard.
Deleting either of the two artboards with that id then deleted the
guides of both, and guide.list reported the copy's guides on the
other one.

push_artboard_copy now takes the id from Document::next_artboard_id,
as artboard.new and Convert to Artboards do. Since the #1718 fix, that
function returns the smallest free id when the largest is u32::MAX.
In a document whose artboard ids are all below u32::MAX, a copy gets
the same id as before.

Docs: docs/target-app-parity.md.
2026-10-16 02:21:46 +07:00
dotCooCoo 83759c0e94 Render tests: two moved raster tests no longer run alongside the tests that add fonts
The key of a raster made for content that shows a symbol includes the
font generation, and since c348b384 (#1723) the key for content
painted with a pattern includes it too. FontDb::add_font increases
that generation, and the six tests in tests_fontchange.rs call it. Two
tests in fx.rs draw such content and count the rasters moved between
frames: moved_rasters_need_the_same_content_and_effects and
a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting.
Neither held fonts_steady(). When a font test added a font between
their frames, the renderer made the raster again and the test counted
0 rasters moved instead of 1. Under CPU load on Windows, with the font
tests in the same process, the first test failed in 12 of 100 runs and
the second failed in 36 of 70.

Both tests now hold fonts_steady().
translucent_blended_and_shadowed_type_redraws_when_its_font_becomes_available
added its font without the font lock that fonts_steady() holds shared,
and it now takes adding_fonts(), as the other five font tests do. With
the change, the first test passed in all 200 runs and the second
passed in all 120 runs under the same load.
2026-10-16 02:21:46 +07:00
dotCooCoo 37c3eb0d01 Tests: the Alt-drag tests no longer fail when their documents are made in different seconds
file.new stamps metadata.created from the clock in whole seconds. Four
Alt-drag tests in tests_xform.rs compare the document under test with
one that a second session builds, and both sessions start from
session()'s file.new. When the clock reached a new second between the
two calls, the documents differed in created, and the test failed.
With a temporary change that made each clock read on a thread at least
one second later than the one before, all four failed.

session() in tests_xform.rs and persp_session() in
tests_persp_select.rs now pass "created": null to file.new, as #1894
did for doc_with_rect() in tests_ids_without_selection.rs. Two new
tests in #2067 compare two documents built on persp_session().
2026-10-16 02:21:46 +07:00
bflatastic f5fcb30745 Type: Option and AltGr still type while Cmd/Ctrl chords don't (#2094) 2026-10-16 02:21:46 +07:00
bflatastic 53eb76f001 Properties: the catalogs drop the flip labels the panel no longer shows 2026-10-16 02:21:46 +07:00
Fabio William ConceiçãoandCursor 2ebc607ed2 Fix #2094: Cmd+Shift+O Create Outlines while typing
Keep Type tool from inserting Text when Cmd/Ctrl/Alt is held so the
chord outlines instead of typing, and cover Selection and Type-tool
paths so File › Open and the Artboard tool cannot steal it.

Co-authored-by: Cursor <[email protected]>
2026-10-16 02:21:46 +07:00
NetterSilverandClaude Haiku 5.5 a8e9b9d86e Make New Document preset cards follow the chosen units
Fixes #2078

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 5162f830d0 Fix New Document orientation on category tabs and preset cards
Fixes #2084

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 62ab3bd797 Make SVG export of type with control characters well-formed
Found while testing crates/svg/src/export.rs:1908

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-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 18c1bb0d55 Apply the sign of a picas+points value to the whole value
Found while testing crates/doc/src/lib.rs:~298 (parse_measure, picas+points branch)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 1524ceae09 Evaluate * and / left to right in field arithmetic
Found while testing crates/doc/src/lib.rs:269-297 (parse_measure, used by Unit::parse and parse_number)

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-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 a59621fa78 Accept a negative operand after an operator in field arithmetic
Found while testing crates/doc/src/lib.rs:272

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
Fabio William ConceiçãoandCursor c53e161e37 fix(G11): Reflect X/Y flip the correct axes (#1910)
Transform Each had Reflect X and Reflect Y swapped relative to Effect › Distort ›
Transform and the Transform panel. Properties flip buttons also used inverted
tooltips. Align them so Horizontal is left-right and Vertical is top-bottom.

Co-authored-by: Cursor <[email protected]>
2026-10-16 02:21:27 +07:00
BFlat 8b22b07e09 Merge pull request #1909 from cecobald/feat/bg-catalog
i18n: add the Bulgarian (bg) catalog
2026-10-16 02:17:32 +07:00
BFlat 8a8b21385b Merge pull request #1761 from euisuh/fix/mcp-protocol-validation
fix(mcp): discard malformed JSON replies before reusing connections
2026-10-16 02:17:08 +07:00
BFlat ccc8a246ae Merge pull request #1886 from marcoarnulfo/fix/text-edit-commit-before-transform
Type: a Transform panel edit or a dialog preview while typing keeps what was typed
2026-10-16 02:16:45 +07:00
BFlat fda8434d93 Merge pull request #1885 from dotCooCoo/feat/distribute-follows-align-to-artboard
Align: Distribute follows Align to Artboard
2026-10-16 02:16:24 +07:00
BFlat 62089aa333 Merge pull request #1923 from marcoarnulfo/fix/move-angle-distance
Move dialog: Distance and Angle, linked to Horizontal and Vertical
2026-10-16 02:15:59 +07:00
BFlat 0a09706453 Merge pull request #2041 from giang17/fix/title-bar-wm-keeps-release
Title bar: a move or resize the window manager finished no longer blocks the next one
2026-10-16 02:15:37 +07:00
BFlat d939e46542 Merge pull request #1472 from LloydNicholson/fix/svg-use-artboards-clip
Place keeps a clipped group's clip
2026-10-16 02:15:17 +07:00
BFlat 47f826b16f Merge pull request #1884 from marcoarnulfo/fix/text-scale-font-size
Info panel: scaled type shows its scaled size, as the Character panel does
2026-10-16 02:14:55 +07:00
BFlat 0ccc501ef9 Merge pull request #1887 from dotCooCoo/fix/issue-1770-pinned-stars
Part of #1770: the pinned Pathfinder test builds its stars from fixed unit vectors
2026-10-16 02:14:32 +07:00
BFlat bdff89b5c1 Merge pull request #2044 from midasmatterhorn/fix/issue-2031-large-canvas-scale
eps: a Large Canvas editing copy is read at the page's scale (#2031)
2026-10-16 02:14:00 +07:00
BFlat d47e5cf461 Merge pull request #2043 from midasmatterhorn/fix/issue-1732-transform-again-after-transform-each
transform: Transform Again repeats a Transform Each (#1732)
2026-10-16 02:13:38 +07:00
BFlat 18186dbeb7 Merge pull request #2042 from midasmatterhorn/fix/issue-2023-transformed-group-images
effects: a Transform effect moves and copies a group's images too (#2023)
2026-10-16 02:13:16 +07:00
BFlat 45922c40ef Merge pull request #2203 from Messhias/fix/2131-clippy-pdf-collapsible-match
fix(pdf): collapse Shading match for clippy 1.95
2026-10-16 02:12:43 +07:00
bflatastic f6b9ab905e i18n(bg): the strings main added since the catalog was written (legacy .ai options, Dimension tool, Revolve cap) 2026-10-16 02:04:36 +07:00
euisuh e9fc3c57ae fix(mcp): discard malformed JSON replies before reusing connections 2026-10-16 02:02:48 +07:00
Marco Arnulfo 256b052981 Type: a Transform panel edit or a dialog preview while typing keeps what was typed (refs #1881)
The Cmd-drag part of #1881 landed in #1897. This covers the two other
paths: an edit from outside the typing session (the Transform panel) and
a new interaction such as a dialog's preview now end the typing first.
2026-10-16 02:02:48 +07:00
dotCooCoo 93742621bc Align: Distribute follows Align to Artboard
With Align to Artboard chosen, the Align panel's six Distribute Objects
buttons and its two Distribute Spacing buttons acted on the selection.
object.distribute and object.distributeSpacing took no to or artboard
param. Distribute Objects kept the outermost objects where they were
and did nothing with two objects, and Distribute Spacing kept the first
object's left or top edge and the last object's right or bottom edge
where they were.

Both commands now take to ("selection", "artboard" or "key") and
artboard, and the panel sends its Align To choice as to. When to is
"artboard", the first object's left or top edge goes to the artboard's
left or top edge, and the last object's right or bottom edge goes to
the artboard's right or bottom edge. Distribute Objects spaces the
other objects evenly by its button's line, and two objects are enough.
Distribute Spacing without a spacing value makes the gaps between the
objects equal. Both commands use the same artboard as object.align.
The app passes the active artboard to both commands, and recorded
actions leave it out, so an action plays on the artboard that is
active when it plays. With "selection" or "key", or without to, both
commands work as before. Distribute Spacing with a spacing value still
keeps the key object in place with each of the three to values. With
anchor points selected, both commands still space the points among
themselves and do not use to.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:02:48 +07:00
bflatastic 8d4a814c81 Move dialog: an angle past 180° reads as its negative one (#1915) 2026-10-16 02:02:48 +07:00
bflatastic f53e7a4c52 i18n(bg): English keeps its own name in the Language menu, as in the other catalogs 2026-10-16 02:02:48 +07:00
cecobald 10b35a4218 i18n(bg): apply terminology (Gradient=Градиент, Bleed=изтичане, Spot Colors=спот цветове) 2026-10-16 02:02:48 +07:00
cecobald ceb69c20d1 i18n(bg): fix typos, artifacts and leaked [@msg] tags (native review) 2026-10-16 02:02:48 +07:00
cecobald 5bcf040659 i18n: add the Bulgarian (bg) catalog
The catalog holds the 4,789 rows the other complete catalogs hold: the whole
interface (every menu string and `tl!` literal) and the status and error
messages, in the Bulgarian vocabulary of vector illustration, following
Adobe Illustrator's Bulgarian conventions; one/other plural forms.

Clean-room from VectorCraft's English meaning, drafted with AI assistance
(Gemini); a native speaker has not reviewed it yet. `bg-BG` and every other
`bg-*` locale resolve here.

`cargo test -p vectorcraft-ui-egui i18n::tests` passes (46 tests, including
complete menu, literal, message and font coverage).
2026-10-16 02:02:48 +07:00
Marco Arnulfo 8e72916a69 Move dialog: Distance and Angle, linked to Horizontal and Vertical (fixes #1915) 2026-10-16 02:02:47 +07:00
Giang Nguyen 07b45e8dd3 contributors: credit giang17 as Giang Nguyen 2026-10-16 02:02:09 +07:00
lloydandClaude Opus 5.5 8d288337f5 Place keeps a clipped group's clip
Place flattened every top-level layer into its objects, so an SVG's clipped top-level group lost
its clip and its clipping path came in as a loose, invisible path; sublayers lost their clips the
same way. A clipping layer is now placed as one clip group, for SVG/DXF and for the art placed from
an artboard (PDF, EPS, placed documents) alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:01:50 +07:00
Marco Arnulfo d0c302c1a3 Info panel: scaled type shows its scaled size, as the Character panel does (part of #1882) 2026-10-16 02:01:50 +07:00
dotCooCoo d425aede37 Part of #1770: the pinned Pathfinder test builds its stars from fixed unit vectors
every_pathfinder_operation_is_pinned built its stars with shapes::star,
which calls the platform's cos and sin. At the star angle
2.204350845268838, cos on macOS x86_64 (measured under Rosetta on
macOS 27.0.1) returns 0xbfe2f1c59f37cbf4, where the correctly rounded
value, which macOS aarch64 returns, is 0xbfe2f1c59f37cbf5. In the set
of five shapes with seed 7, this moves one star vertex by one ulp, and
the set's Unite hashes to 60181664961792917, where PINNED has
9388160212451732406. Under Rosetta, 21 of the 40 hashes differ.

The test now builds each star from a table of the correctly rounded
cosine and sine of the star's ten angles, written as f64 bit patterns,
with the arithmetic of shapes::star. PINNED is unchanged. With these
inputs, all 40 hashes equal PINNED on aarch64 and under Rosetta.
2026-10-16 02:01:50 +07:00
bflatastic 7beebfabb9 eps: apply the Large Canvas scale to EPS files only; a .ai keeps its art space (#2031) 2026-10-16 02:01:50 +07:00
midasmatterhorn 3af1976d19 transform: Transform Again repeats a Transform Each (#1732) 2026-10-16 02:01:50 +07:00
midasmatterhorn d5abc71677 effects: a Transform effect's image copies stay within a node budget (#2042) 2026-10-16 02:01:50 +07:00
Giang Nguyen 6986d87771 Title bar: a move or resize the window manager finished no longer blocks the next one (fixes #2040) 2026-10-16 02:01:50 +07:00
midasmatterhorn 26a9c7a4cc eps: a Large Canvas editing copy is read at the page's scale (#2031) 2026-10-16 02:01:50 +07:00
midasmatterhorn ff79f332a7 effects: a Transform effect moves and copies a group's images too (#2023) 2026-10-16 02:01:50 +07:00
Fabio William ConceiçãoandCursor fc14c38883 fix(pdf): collapse Shading match for clippy 1.95 (#2131)
Rust 1.95 clippy -D warnings rejects the nested if under TypedInstruction::Shading
(collapsible_match). Fold the shading lookup into the match arm guard so the Ubuntu
xtask ci gate can pass on the same toolchain Actions pins.

Co-authored-by: Cursor <[email protected]>
2026-10-16 02:01:49 +07:00
BFlat f5eaf75097 Merge pull request #1879 from RICHRAN2030/feature/image-trace-adjustments
Fix Image Trace adjustment discovery and slider commits
2026-10-16 01:58:20 +07:00
BFlat 21749106d3 Merge pull request #1794 from danngreen/feat/legacy-ai-export
Add Legacy .ai export (keeps groups and layers)
2026-10-16 01:58:03 +07:00
BFlat cc0aec12ae Merge pull request #1788 from osamaamin2011/dimension-tool
Fix #1372: Dimension tool
2026-10-16 01:57:45 +07:00
BFlat a23b2370bc Merge pull request #1740 from gniax/touch-pen-input
Web: Apple Pencil and touch input (palm rejection, pen pressure with coalesced events)
2026-10-16 01:57:27 +07:00
BFlat e18efebf60 Merge pull request #1193 from alpha-rock/codex/revolve-end-caps
Revolve: smooth vector shading and continuous end caps
2026-10-16 01:57:11 +07:00
BFlat ef959c00bd Merge pull request #1403 from vbasky/fix/1326-numeric-drag
Numeric fields: a short drag the toolkit counts as a click keeps its caret (#1326)
2026-10-16 01:56:54 +07:00
BFlat e2d796b555 Merge pull request #2244 from dotCooCoo/fix/pdf-nested-stream-checks
Importers: PDF streams written inside arrays and dictionaries are checked before they are decoded
2026-10-16 01:56:38 +07:00
BFlat 268c2f2703 Merge pull request #2240 from dotCooCoo/fix/pdf-unparseable-color-values
PDF/.ai import: dictionary values the reader can't parse no longer crash the ink and font scans
2026-10-16 01:56:21 +07:00
BFlat c9ead98f1c Merge pull request #2133 from dotCooCoo/fix/pdf-unreadable-object-panics
Importers: a PDF dictionary value the reader can't parse no longer crashes the nesting check
2026-10-16 01:56:04 +07:00
bflatastic baadbe2c67 Image Trace: a quick trace leaves the panel as it was; the Image Trace button opens it 2026-10-16 01:36:25 +07:00
Dan Green ce133351fd Part of #868: legacy .ai writer drops its own depth limit
Documents are now at most vectorcraft_doc::MAX_DEPTH (32) deep, so the writer's MAX_DEPTH of 256
and its warning for art nested deeper can go; symbols and live objects nested in one another keep
their limit.
2026-10-16 01:36:25 +07:00
RICHRAN2030 313b1a52b9 fix(UI.TRACE): expose trace adjustments and reliably apply and undo them 2026-10-16 01:36:25 +07:00
Dan Green 7844f46c65 Part of #868: legacy .ai runs as a PostScript program, with a prolog of its own
The file had an empty prolog, so a PostScript interpreter stopped at its first operator: it
couldn't be printed, placed or previewed by anything that runs PostScript. The prolog's
procedure set (VectorCraft_Legacy, written from the PostScript Language Reference and the
version 7 specification) now gives every operator the writer emits its meaning: paths and their
painting with separate fill and stroke colours and overprints, the fill rule, compound paths
painted as one path, clipping groups and masks, guides that don't print, layers that are hidden
or don't print marking nothing, and images drawn from the hex samples on the comment lines after
XI. Gradients fill with the colour set before them (the first stop's), and the type and encoding
operators take their operands and mark nothing.

VectorCraft opens a file with that prolog from its program, as before: its page has flat
gradients and no type.

Every file the writer's tests write is now run through import_with(bytes, false) and must draw
what the document it was written from draws: its paths, fills, strokes and images in the same
places and colours, with type left out (type the writer outlines is compared as its glyphs'
outlines), gradients as their first stop, layers that don't print left out and hidden objects
shown when the options write them visible. A new test prints the sample with every layer shown.
The engine's flattened-image test also opens the file as PostScript.
2026-10-16 01:36:25 +07:00
Dan Green 06221726fb Part of #868: legacy .ai defines annotatepage in a prolog of its own
The page trailer the version 7 specification gives (section 3, Script Setup) is
`gsave annotatepage grestore showpage`, and annotatepage is a procedure of the app's own
prolog, which the file didn't have. The file now carries a procedure set of the writer's own
(VectorCraft_Legacy 1 0, listed in %%DocumentSuppliedResources) in a dictionary the setup begins
and the trailer ends, which defines annotatepage as marking nothing.
2026-10-16 01:36:25 +07:00
Dan Green 37cebc47ee Part of #868: vectorcraft-cli convert writes legacy .ai (--ai-version 7, --hidden-objects, --transparent-images) 2026-10-16 01:36:25 +07:00
bflatastic 7217dcd121 Dimensions: a file whose dimensions carry options this version doesn't know still opens; commands still refuse unknown options 2026-10-16 01:36:24 +07:00
gniax 6f23190925 fix(web): preserve pen buttons and touch-only input 2026-10-16 01:36:24 +07:00
Dan Green 67e17c1b66 Part of #868: legacy .ai options Hidden Objects and Text, and docs
The Legacy .ai Options dialog gains Hidden Objects (Hidden Layer, Omit, Make Visible) and Text
(Editable, Outlines), translated in every catalog. Docs: type and hidden objects in the legacy
.ai paragraphs, the restored revision rows and roadmap row.
2026-10-16 01:36:24 +07:00
Osama Amin 8aea665c4e Dimension tool: the documented gestures, arcs, picas and text positions; Properties section; review fixes
Drag out from a segment, corner, circle or arc to place a dimension; plain clicks pick two points or two lines. Radial dimensions on perfect arcs, picas, Away/Between and Left/Center/Right text placement. The Properties panel edits a selected dimension, with Apply to All (dimension.applyToAll) and Set as Default. Dimension Selected Paths, the Arc/R/Ø prefixes and the custom defaults are left out. The refresh returns at once in documents without dimensions (Document::dimensions); a linked dimension moved on its own keeps its place; modes, units, line types, arrows and text placement are enums; the tool has its own icon.
2026-10-16 01:36:24 +07:00
gniax c2d832ebc0 docs: pen and touch input on the web in development and hardware parity 2026-10-16 01:36:24 +07:00
Dan Green 6ce1a622be Part of #868: legacy .ai type as text objects and hidden objects kept; legacy type read
Type is written as version 7 text objects (§15): point type, area type and type on a path, with
fonts by PostScript name re-encoded to Windows-1252, and each run's paint, size, scaling,
tracking, leading, alignment and indents; type 8-bit text can't hold (or all, with outlineText)
is outlines. The format hides only layers, so hidden objects go to a hidden layer above their
own (hiddenObjects "layer", the default), are left out ("omit") or show ("visible"). The .ai
reader reads legacy type (To … TO, TE, %AI3_BeginEncoding) and ignores Ar; the import fuzzer
gets legacy type tokens.
2026-10-16 01:36:24 +07:00
Osama Amin 9d813e906a Fix #1372: Dimension tool and Object > Dimensions: linked length, angle, radius and diameter annotations 2026-10-16 01:36:24 +07:00
gniax 9fc7fd7038 feat(canvas): pen strokes keep every position reported between frames
A pen reports far more often than frames come (an Apple Pencil up to 240 times
a second): the canvas now hands the tool each move of the frame with its
pressure instead of the last one only, and the web shell feeds Safari's
coalesced pen events. Mouse input is unchanged.
2026-10-16 01:36:24 +07:00
Dan Green 32df15a554 Part of #868: docs for Save As .ai legacy version 7 (artboard picker, Transparent Images) 2026-10-16 01:36:24 +07:00
Dan Green 91ed4853a8 Part of #868: legacy .ai options dialog, Transparent Images and an artboard picker
The .ai options dialog offers Legacy (version 7). In that mode it shows Transparent Images
(Flatten, Omit) and, with more than one artboard, thumbnails to check with the file each is
saved to (long names keep their end). OK is greyed out until an artboard is checked. A save
panel's Enter no longer confirms the options dialog it opens.
2026-10-16 01:36:24 +07:00
Dan Green bcae7ca486 Part of #868: Save As .ai offers Legacy (version 7), with Transparent Images and an artboard range
aiVersion 7 saves through the legacy writer, one file per artboard; range picks the artboards
and document.save writes them again. Appearances are expanded first. Transparent images
(see-through pixels, opacity or a blending mode) are flattened with the art under them into
opaque images clipped to where they change it (transparentImages "flatten", the default), or
left out ("omit"); both warn.
2026-10-16 01:36:24 +07:00
Dan Green eb320593dc Part of #868: legacy .ai writer to the published version 7 specification
export_legacy_ai writes a document as a PostScript-form .ai of version 7 (one artboard per
file), keeping every layer (name, order, Hidden, Locked) and its groups, compound paths and
clip groups. Closed paths end on their start point, so they keep their corner. The .ai reader
learns what the writer emits, and the tests read every file back.
2026-10-16 01:36:24 +07:00
bflatastic b5b9264d71 Revolve: the Cap option is saved as cap, as Extrude & Bevel and Inflate save it 2026-10-16 01:36:23 +07:00
Vikram Bhaskaran 0c11ff656c Format the reworked select_all_on_focus. 2026-10-16 01:36:23 +07:00
dotCooCoo 433d78284d Importers: PDF streams written inside arrays and dictionaries are checked before they are decoded
A PDF is refused when a stream in it would decode to more than 256 MB
or has decode parameters the reader can't apply, but the check looked
only at the file's objects. A stream written directly inside an array
or a dictionary, such as the /Function of a shading, was not checked.
On main at b12a52f7, a calculator function holding 257 MiB of deflated
spaces opened after 3.0 to 4.3 s with a peak of 550 to 818 MB when it
was written in an array, in a dictionary or as a shading's function.
The same stream as an object of its own was refused in 81 ms.

hostile in pages.rs now runs unsafe_stream on every stream that the
walk of the file's objects meets, at any depth in arrays and
dictionaries, before it decodes the stream as a calculator function.
It refuses such a file with the same message. The walk is the one the
nesting check made before (64 deep at most).
2026-10-16 01:36:23 +07:00
gniax 51673cd098 feat(web): Apple Pencil input on iPad with palm rejection
Read pen and finger Pointer Events in the web shell (capture phase) instead of
eframe's touch handlers, which can't tell a pen from a finger and see every
touch twice. The pen drives the pointer with its pressure and hovers; fingers
are touch contacts for pinch, two-finger pan and undo/redo taps. Once a pen
has been seen (remembered in localStorage), a finger or a resting palm never
presses a tool on the canvas, and a pen landing cancels fingers already down.
A long press drops the tool press and opens the context menu.

The page no longer scrolls, zooms or selects under touch, and the canvas
stays inside the safe areas of a full-screen app.
2026-10-16 01:36:23 +07:00
alpha-rock f703e6bd85 M9: smooth Revolve rendering and default Cap
Use automatic vector smooth shading without a separate control.
Enable Cap by default and keep continuous rim curves and bounded seam joins.
Integrate the shared dialog with the other three 3D effects.
2026-10-16 01:36:23 +07:00
Vikram Bhaskaran 173c88d2a1 Rework #1326: a short drag the toolkit counts as a click keeps its caret.
The late-focus case in the original change has no reproducible path
(#1339 already covers focus on the press frame), so the button-down
defer is dropped. The real issue is the clicked_focus branch: a very
short drag inside one character produces no text selection, egui still
reports a click, and select-all on release replaced the drag. The press
position is now remembered and a release more than three points away
keeps the caret; a plain tap still selects all.

Also fixes the test placement bug: the new test sat between the #[test]
attribute and keyboard_focus_keeps_selecting_the_entire_numeric_field,
so that test no longer ran. The regression test fails on the fork-point
widgets.rs and passes with this change.
2026-10-16 01:36:23 +07:00
Vikram Bhaskaran 8354dbcd74 Keep a numeric field caret when focus arrives while the pointer is still down.
Select-all ran on that late focus frame and replaced the drag the press had started.
2026-10-16 01:36:23 +07:00
bflatastic cf98b7f368 PDF import: one helper for the dictionary values the reader can parse 2026-10-16 01:36:22 +07:00
dotCooCoo 9e90464f20 PDF/.ai import: dictionary values the reader can't parse no longer crash the ink and font scans
The reader's Dict::entries unwraps every value it reads, so it panics on
a value it cannot parse, such as the name /#zz or the number 12-.5
(vendor/hayro-syntax/src/object/dict.rs:104 at craft-libs ca50d63b).
Besides the nesting check in pages.rs, two loops of the import call it:

- Colors::visit (import_color.rs) looks for the file's Separation and
  DeviceN spaces when a page paints in one. craft_pdf's interpret_page
  catches the panic, and the import fails with "PDF page interpretation
  failed internally".
- Walker::note_fonts (import_scan.rs) notes the fonts of each page. The
  import does not catch that panic.

On main the nesting check in pages.rs panics on such a value first
(#2133 fixes it); with it fixed, the import reaches these two loops.

Colors::visit now reads the values with Dict::keys and get_raw, sorted
by key as before, and leaves out a value the reader cannot parse.
note_fonts reads the keys and looks each font up by its key, as before.
2026-10-16 01:34:10 +07:00
dotCooCoo a69ddea930 Importers: a PDF dictionary value the reader can't parse no longer crashes the nesting check
The shared PDF reader (craft_pdf::syntax, craft-libs ca50d63b) indexes
a dictionary by skipping its values. Its skip accepts some values that
its parse refuses, such as a name with a broken # escape (/#zz) and a
number such as 12-.5. Dict::entries unwraps the parse of each value
(vendor/hayro-syntax/src/object/dict.rs:104). The check in pages.rs
that refuses functions and color spaces built on one another without
end (nests_without_end) calls entries on every dictionary of a file
when the file opens. So document.pdfInfo, PDF import,
clipboard.importPdf and loading a .ai panicked on such a file, and
three import fuzz tests fail on main with PROPTEST_RNG_SEED=1182 and
PROPTEST_CASES=20000.

The check now reads a dictionary's values with keys and get_raw and
leaves out a value that doesn't parse.

A test in crates/pdf puts each of those values in a file with a plain
function, which opens, and in a file with a function that stitches
itself, which is still refused.
2026-10-16 01:34:09 +07:00
andeecollardandClaude Opus 5.5 cbe793e059 Image Trace: grayscale gradients use grey stops
In Grayscale mode with Gradients on, the trace could fill paths with
coloured linear gradients (for example a blue-to-orange ramp) beside
grey flat fills. Gradient detection ran on the RGB image, so the stops
and the gradient's palette colour (its middle) were the source colours,
while Grayscale's flat fills come from each pixel's luma.

Grayscale now finds gradients, and settles their edges, in a copy of the
image reduced to the same Rec. 601 luma the flat grays are quantised
from. Every channel of that copy is equal, so the fitted stops, the ends
carried to the area's edges and the palette entry are all grey. Color
mode is unchanged: it still detects on the RGB image (trace output for
gradient fixtures is byte-identical before and after).

Adds grayscale_gradients_are_grey: a code-drawn sky ramp over flat
shapes with a ramp rectangle, traced in Grayscale with 4 grays and
Gradients on, must still produce a gradient, and every path colour,
gradient stop and palette entry must be grey.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-15 18:14:17 +00:00
andeecollardandClaude Opus 5.5 210b98e902 Image Trace: regression tests for shared closed boundaries
Abutting traces must draw each edge between two neighbouring regions once:
the filled paths on either side follow one curve, one of them reversed, and
together cover the image without gaps or overlaps. These tests check that
from the trace output alone, with no renderer: points along each path's
outline must lie within 0.001 px of another path's outline, the paths' areas
must sum to the image's, and every point of a 4 x 4 per-pixel grid must lie
in exactly one path.

The images are drawn in code, hard-edged and anti-aliased, and traced in
Color (Abutting) and Black and White, each at the default settings, Paths and
Corners 0 and 100, Snap Curves To Lines, and Shapes. They cover a closed island
in a background, a ring with and without a core of a third colour, a wavy
two-colour seam, a three-region junction, outlines that leave a junction and
return to it (the 111 / 101 / 110 pattern at 1, 2, 3 and 6 px, and framed),
squares touching at a corner, a one-pixel hole and a one-pixel island (kept at
Noise 1, dropped together with what they leave at Noise 25), separate areas of
one colour, and slanted hatches with one-pixel paper channels. A further test
shows the check finds a 0.01 px shift and stacked (Overlapping) layers.

Two tests are ignored as documented current gaps:
- At Paths 0 and Corners 0, a piece returning to its own junction collapses:
  its region is dropped and its area left uncovered (draft #2205 fixes it).
- With Shapes, a region whose outline runs through a junction becomes an exact
  shape its neighbour does not share (up to 0.0039 px in Black and White).

No tracer code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-15 18:08:58 +00:00
andeecollardandClaude Opus 5.5 56df5969f5 Image Trace: keep shared corners identical at nearby junctions
When a long horizontal or vertical run crosses a three-colour junction one
or two pixels before a corner, the corner correction for that run borrowed
a neighbouring crack's shift that the region on the other side of the
junction never applied, leaving hairline gaps between abutting colours.
The correction now applies only when all three cracks it uses lie in the
same shared piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-15 18:06:48 +00:00
skylar taylor-barrick b12a52f724 PDF.R1: integrate guarded shared PDF writing and reading (#1894)
Integrate the shared PDF writer and reader as one qualified final tree. Preserve the separate writer and reader commits and review records.

All eight local CI gates and all eight hosted jobs passed; the checked source tree matches the final integration.
2026-10-14 22:46:16 -06:00
skylar taylor-barrick 05f6e2e6df PDF.R1: reconcile shared PDF migration with current main
Preserve the writer and reader changes while integrating the current main.

The combined candidate passes all eight local CI gates and the 2,048-case
reader panic verification. Hosted checks remain required before landing.

Refs #1893, #1894.
2026-10-14 22:02:49 -06:00
skylar taylor-barrickandskylartaylor 0946bc5b74 ci: add comfortable runtime limits to unbounded jobs (#2077)
Co-authored-by: skylartaylor <[email protected]>
2026-10-15 05:16:14 +02:00
Brandon ThomasandClaude Opus 5.5 0a33cff3f6 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:05:33 -04:00
skylar taylor-barrick d80950b3f3 CI: isolate the font cache test from parallel font loads 2026-10-14 06:40:06 -06:00
skylar taylor-barrick 664e2cec5f CI: keep command comparison fixtures independent of the clock 2026-10-14 05:10:20 -06:00
skylar taylor-barrick 2ecba1fb9c PDF.R1: reconcile shared reader with current writer 2026-10-14 03:59:03 -06:00
skylar taylor-barrick 52960faf63 PDF.W1: reconcile shared writer with current main 2026-10-14 01:23:19 -06:00
BFlat a067177778 Merge pull request #1223 from dotCooCoo/feat/shortcut-sets
Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
2026-10-14 14:12:14 +07:00
bflatastic d9c9526898 Keyboard Shortcuts: shortcut set reading and matching stay bounded on hostile files
A set whose entries sit in long, deeply nested sections kept every section's names again for each entry (up to hundreds of megabytes from a 4 MiB file); the names kept over all entries now stop at 8 MiB. Name matching compares character pairs in linear time, so a 20,000-entry set of long names is matched in about 2 s instead of 17 s.
2026-10-14 13:49:59 +07:00
dotCooCoo 8c889279f8 Keyboard Shortcuts: importMatches.list answers an unknown parameter key with an error
Since #1442 (3acc29f8), a caller outside the app gets an error for a
parameter key that the command's params description doesn't name, and
every_description_parses requires each top-level entry of the params
object to start with a key. The note on query in importMatches.list had
a comma at the top level ("text in the name, target or label"), so the
description didn't parse: the test failed, and the command took any key.
The note is now in parentheses, and the command takes kind and query.
A test checks that shortcutSet.read and the three importMatches
commands take every key they read and refuse a misspelled one.
2026-10-14 13:22:08 +07:00
dotCooCoo d1108bdbca i18n: Korean, Polish and Vietnamese rows for Import Shortcut Set and its commands
Since #1352, #1498 and #1507, Korean, Polish and Vietnamese are complete
catalogs: every menu string, tl! literal and status or error message has
a row, and the i18n tests check it. ko.tsv, pl.tsv and vi.tsv get the
rows that the other complete catalogs have for Import Shortcut Set and
its commands: the dialog's strings, the labels Import Shortcut Set…,
Read Shortcut Set File, Import Matches, Remember Import Matches and
Forget Import Matches, the two plurals and the two error messages. Copy
Report and Not read are already in all three, and Shortcut and the
Imported message in pl, so they are not repeated. The dialog's rows go
after Import Print Presets in ko and vi and after the Keyboard Shortcuts
dialog's rows in pl, and the two messages after "not a VectorCraft
shortcut set".
2026-10-14 13:22:08 +07:00
dotCooCoo 04244fa2a2 i18n: remove Import Shortcut Set rows that the catalogs already have
Check Script (3374d077) added Copy Report and Not read to every catalog,
and es and fr gained Shortcut and the Imported message in a747daa3 and
9e39a6d1. The rows that Import Shortcut Set added for the same strings
became second entries, which bundled_catalogs_are_consistent rejects.
They are removed, 26 rows in cs, de, es, fr, hu, it, ja, ru, uk, zh-hans
and zh-hant, and the existing rows stay as they are.
2026-10-14 13:22:08 +07:00
dotCooCoo 612a13b39c i18n: Hungarian rows for Import Shortcut Set and its commands
Since b9716366 (#1302), Hungarian is a complete catalog: every menu
string, tl! literal and status or error message has a hu row, and the
i18n tests check it. hu.tsv gets the rows that the other complete
catalogs have for Import Shortcut Set and its commands: 35 strings, the
two plurals in the single form hu.tsv uses (a count takes the
singular), and the two error messages. Shortcut and the Imported
message already have hu rows on main, so they are not repeated. The
rows are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo f1e634af19 i18n: Czech rows for Import Shortcut Set and its commands
Since #1189, Czech is a complete catalog: every menu string, tl! literal
and status or error message has a cs row, and the i18n tests check it.
cs.tsv gets the rows that the other complete catalogs have for Import
Shortcut Set and its commands: 35 strings, the two plurals with one, few
and other forms, and the two error messages. Shortcut and the Imported
message already have cs rows on main, so they are not repeated. The rows
are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo 8c14609370 Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
A shortcut set another app saved as slash-name text (a .kys file whose
text starts with a slash name and "{", such as "/Menus {") opens in
Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts ›
Import… in the desktop app, or from a drop on the window, whether or
not a document is open. The dialog proposes a target for an entry when
an earlier import kept one for that name, or else when one tool,
command or panel matches the name clearly by its id or by one of its
labels. It decodes the file's key and modifier codes from the codes
earlier imports kept, from the example in the AiCommandPalette README
(/Key 48 for the 0 key, with /Modifiers 0 taken as no modifiers), and
from VectorCraft's default shortcuts where at least two entries vote and
four votes in five agree. The user picks the remaining targets, presses
the key for each unknown key code or marks it No equivalent, and chooses
the modifiers for each unknown modifier code. Apply assigns the ready
rows with the Keyboard Shortcuts rules: a tool takes a single key,
optionally with Shift. It goes through the rows from the last one back
and leaves a row out when a later row it applies has the same shortcut
or the same tool, command or panel. Keyboard Shortcuts then shows the
result as unsaved changes until the user clicks OK. Copy Report copies
the set's name, the rows Apply leaves out and the parts of the file that
weren't read.

- shortcutSet.read is a query command: it reads a set and does not
  modify the document or the preferences. It returns each entry with
  its section, codes and line, and up to 1,000 skipped tokens, each cut
  to 80 characters. It reads at most 4 MiB of text and 20,000 entries,
  skips blocks nested more than 16 deep and cuts names at 1,024 bytes.
  With a path it reads at most 4 MiB and 4 bytes of the file, and with
  --automation-read-root (#832) the path must lie inside the read root.
  A file whose text starts with "<", such as an XML shortcut file saved
  as UTF-8, fails with "This shortcut file is in a form VectorCraft
  can't read."
- importMatches.list, importMatches.set and importMatches.reset read
  and change the matches kept with the preferences; resetting the
  preferences keeps them. With Remember My Choices on (the default),
  Apply keeps the targets and codes the user chose, and for the rows it
  assigns, it also keeps the matched targets and the codes decoded by
  agreement. A later import proposes them.
- The name matcher compares a name with ids by exact match and shared
  words. It compares the name with labels, in English and in the
  interface language, by exact match, shared words and character
  pairs. It proposes a match only when one tool, command or panel
  scores at least 0.6 and at least 0.1 more than the next one.
- When Import Shortcut Set is opened from Keyboard Shortcuts, Cancel,
  Escape and ui.dialog.cancel close it and return to Keyboard Shortcuts
  as it was (dialogs::PARENT). A document's queued Missing Fonts dialog
  (#792) opens after Keyboard Shortcuts closes.
- File › Open has a "Shortcut sets" filter, and in the desktop app
  Keyboard Shortcuts' Import… shows .json and .kys files.

Docs: docs/mcp.md, docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md, docs/roadmap.md.
2026-10-14 13:22:07 +07:00
BFlat b076025a31 Merge pull request #1897 from storytold/fix/1881-typing-cmd-drag
Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
2026-10-14 12:38:12 +07:00
BFlat 3bb87989df Merge pull request #1896 from storytold/fix/pattern-tiles-brush-cms
Pattern tiles after brush edits and Color Settings; MCP docs on failed batches
2026-10-14 12:38:00 +07:00
BFlat a40e8f5aff Merge pull request #1895 from storytold/fix/1866-section-highlight
Fix #1866: Preferences and Save PDF highlight the section shown
2026-10-14 12:37:48 +07:00
BFlat 1a8e0456cb Merge pull request #1880 from marcoarnulfo/fix/tab-leader
Text: draw tab leaders in the tab's gap
2026-10-14 12:37:27 +07:00
bflatastic 7f1ad31a7b Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
With the Type tool editing, a press with Ctrl (Cmd on the Mac) lends the
Selection tool for the drag. Its drag joined the open typing session, so its
preview started from the document before the typing: the type moved or
scaled and lost what had just been typed. The typing is now kept as its own
undo step before the lent tool takes the press, as Undo already does, and
typing goes on after the drag.
2026-10-14 12:27:04 +07:00
bflatastic 6fd31d4dbd MCP docs: a failed batch leaves the files its steps wrote
The rollback of a failed command.batch (#1862) puts the documents back, not
the disk. Say so, and how to keep a batch from writing unless it succeeds.
2026-10-14 12:27:04 +07:00
bflatastic 6bc0222074 #1723 follow-up: pattern tiles are drawn again after a brush edit or new Color Settings
A tile was kept while the pattern's art and what it shows were unchanged,
but a brush the art is stroked with lives in the document's other data, and
every tile is drawn through the color settings. A tile whose art reads more
than itself now also remembers the document's other data, and every tile
remembers the color settings it was drawn through (both weakly).
2026-10-14 12:27:04 +07:00
skylar taylor-barrick e01a65d8a9 PDF.R1: integrate shared reader with bounded nesting checks 2026-10-13 21:58:01 -06:00
skylar taylor-barrick a699a26221 refactor(pdf): use shared writer for export and print (PDF.W1) 2026-10-13 20:41:14 -06:00
525 changed files with 87521 additions and 18474 deletions

No files matched your search

+22 -87
View File
@@ -1,19 +1,25 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
workflow_dispatch:
concurrency:
@@ -25,80 +31,9 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
# The checkout above is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
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
# 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/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
run-package-check: true
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
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
+4 -45
View File
@@ -19,48 +19,7 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.vectorcraft" and m["command"] == "vectorcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
# 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/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
+23 -488
View File
@@ -1,11 +1,5 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "VectorCraft v<version>". See docs/releasing.md.
#
# Jobs that sign (macOS, Windows) and the job that creates the release run in the `release`
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# other jobs sign nothing and get no environment, so a `workflow_dispatch` on any branch is a dry
# run: they build and upload artifacts, the signed jobs are refused, and no release is drafted.
name: Release
on:
@@ -19,492 +13,33 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
VECTORCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets here.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: Linux packages are not signed (checksums in SHA256SUMS.txt).
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/vectorcraft$|metainfo|applications'
ldd target/release/vectorcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.vectorcraft.yml')); assert m['id'] == 'ai.storyteller.vectorcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
# No APPIMAGE_EXTRACT_AND_RUN here: with it the runtime passes --appimage-* flags on
# to the app instead of answering them itself.
"$appimage" --appimage-updateinformation \
| grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image and packages as
# freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR 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
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
if: inputs.version != ''
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package in a FreeBSD VM
# v1.5.9, pinned by commit.
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed through by name; never templated into the script.
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
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
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets here.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
# The riscv64 variants of the linux job's -dev packages.
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
desktop-file-utils appstream
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the package
run: |
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
name: Draft GitHub Release
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
# drafts a release.
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="VectorCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# 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/release.yml@main
with:
app-name: vectorcraft
app-title: VectorCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+24 -81
View File
@@ -1,22 +1,27 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
# Windows ARM64 build and smoke test.
#
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
# on GitHub Actions' native windows-11-arm runner.
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
workflow_dispatch:
concurrency:
@@ -26,72 +31,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: vectorcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'vectorcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# 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/windows-arm64.yml@main
with:
app-name: vectorcraft
app-dir: VectorCraft
+31
View File
@@ -117,6 +117,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dimension.svg` | Osama Amin | Original work, drawn for VectorCraft: a dimension line with arrowheads between two extension lines | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -133,6 +134,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-draw-normal.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-ellipse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-fill-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-filter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-flare.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-folder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-free-transform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -323,6 +325,11 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `docs/images/shot-3-sheet.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-4-bezier.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-5-perspective.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/perspective-city.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-6-revolve.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-7-dimensions.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/bracket-drawing.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-still-life.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
| `examples/still-life.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/bracket-drawing.vectorcraft` | VectorCraft contributors | Original drawing built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `docs/images/art-neon-drive.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/neon-drive.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-ribbons.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-pathfinder.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/feature-sheet.vectorcraft` (artboard 1) | MIT OR Apache-2.0 | |
@@ -333,3 +340,27 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
## Assets embedded by the PDF backend
The shared PDF backend keeps the existing standard-font and CMap features. These files remain in dependency sources; no font files are copied into this repository. Hashes identify the pinned dependency contents.
| Crate / file | Author | Source | Licence | SHA-256 | Use |
| --- | --- | --- | --- | --- | --- |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
Generated
+82 -77
View File
@@ -806,6 +806,17 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"hayro-interpret",
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1304,12 +1315,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fearless_simd"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1378,24 +1383,13 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree 0.20.0",
]
[[package]]
name = "fontdb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
]
@@ -1766,9 +1760,9 @@ dependencies = [
[[package]]
name = "hayro-ccitt"
version = "0.4.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
@@ -1782,9 +1776,8 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
version = "0.7.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -1794,28 +1787,27 @@ dependencies = [
"phf",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.46.2",
"skrifa 0.47.0",
"smallvec",
"yoke",
]
[[package]]
name = "hayro-jbig2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"fearless_simd 1.1.0",
"fearless_simd",
"hayro-ccitt",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.4.1"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
dependencies = [
"fearless_simd 1.1.0",
"fearless_simd",
]
[[package]]
@@ -1826,9 +1818,8 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -1837,6 +1828,7 @@ dependencies = [
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
@@ -2227,8 +2219,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3839,16 +3830,6 @@ dependencies = [
"read-fonts 0.41.0",
]
[[package]]
name = "skrifa"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
dependencies = [
"bytemuck",
"read-fonts 0.43.3",
]
[[package]]
name = "skrifa"
version = "0.47.0"
@@ -4011,6 +3992,17 @@ dependencies = [
"serde",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -4395,6 +4387,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -4506,7 +4507,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4541,7 +4542,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4552,7 +4553,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4564,7 +4565,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4580,7 +4581,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4602,7 +4603,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4614,7 +4615,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4631,8 +4632,9 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde_json",
"vectorcraft-color",
@@ -4646,7 +4648,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4682,7 +4684,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4703,7 +4705,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"flate2",
"proptest",
@@ -4718,7 +4720,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4730,7 +4732,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"log",
@@ -4749,7 +4751,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4763,7 +4765,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4775,18 +4777,18 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"aes",
"craft-pdf",
"flate2",
"hayro-interpret",
"hayro-syntax",
"image",
"jpeg-encoder",
"krilla",
"kurbo 0.13.1",
"log",
"md-5",
"roxmltree 0.21.1",
"serde",
"serde_json",
"sha2",
@@ -4805,7 +4807,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -4819,7 +4821,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"flate2",
"gif",
@@ -4835,23 +4837,25 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-plugins",
"vectorcraft-testkit",
"vectorcraft-text",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
"wat",
"weezl",
]
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4875,7 +4879,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4892,7 +4896,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4912,7 +4916,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4920,7 +4924,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4928,6 +4932,7 @@ dependencies = [
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-text",
@@ -4935,7 +4940,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4947,7 +4952,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"craft-ui",
"egui",
@@ -4981,7 +4986,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -5004,7 +5009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -5019,7 +5024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -6091,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+18 -4
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
@@ -20,7 +21,9 @@ vectorcraft-doc = { path = "crates/doc" }
vectorcraft-pathops = { path = "crates/pathops" }
vectorcraft-brush = { path = "crates/brush" }
vectorcraft-trace = { path = "crates/trace" }
vectorcraft-text = { path = "crates/text" }
# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a
# library crate never forces them on an embedder that supplies its fonts at run time.
vectorcraft-text = { path = "crates/text", default-features = false }
vectorcraft-effects = { path = "crates/effects" }
vectorcraft-three-d = { path = "crates/three-d" }
vectorcraft-render = { path = "crates/render" }
@@ -58,9 +61,14 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
wat = "1.228"
# rendering / formats
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
# vectorcraft-text gives it. The other default features stay.
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48"
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
# crate turns `text` on.
usvg = { version = "0.48", default-features = false, features = ["svgz"] }
# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are
# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on.
krilla = { version = "0.8", default-features = false, features = ["raster-images"] }
@@ -111,3 +119,9 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
+20
View File
@@ -72,3 +72,23 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Shared PDF backend
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
are fetched from https://github.com/storytold/craft-libs at revision
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
Assets already bundled through Hayro's default features retain their licences:
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
Adobe and contributor names do not endorse or promote this product.
Per-file hashes and sources are recorded in ASSETS.md.
+19 -3
View File
@@ -73,6 +73,16 @@
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-6-revolve.png" alt="A still life of a vase, a bowl, a sphere and a bottle made with 3D Revolve; the vase is selected and the Revolve dialog shows its rotation, cap and view settings beside the rotation gizmo" width="100%">
<p align="center"><sub><b>3D Revolve</b>: smooth vector shading, caps and a rotation gizmo · <code>examples/still-life.vectorcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-7-dimensions.png" alt="A bracket drawing in the light theme with linear, angular, diameter and radius dimensions; the Dimension tool is active and the Properties panel shows the selected dimension's units, precision and style" width="100%">
<p align="center"><sub><b>Dimension tool</b>: lengths, angles and radii that follow the art they measure · <code>examples/bracket-drawing.vectorcraft</code></sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-5-perspective.png" alt="Two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset, with the Perspective Selection tool and the Plane Switching Widget" width="100%">
<p align="center"><sub><b>Perspective Grid</b>: art attached to its planes stays editable in perspective · <code>examples/perspective-city.vectorcraft</code></sub></p>
@@ -108,6 +118,12 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/images/art-still-life.png" alt="Still life: a teal vase, a coral bowl, a white sphere and a mustard bottle on a warm table, all made with 3D Revolve" width="100%">
<p align="center"><sub><b>Still Life</b>: four revolved profiles with smooth vector shading</sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/art-repeat.png" alt="Radial Repeat mandala: teal petals around a coral center, ringed by yellow dots" width="100%">
<p align="center"><sub><b>Radial Repeat</b>: a live mandala</sub></p>
@@ -211,7 +227,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
all of Illustrator): everyday
vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
@@ -220,7 +236,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
@@ -229,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 (27 of 57 still missing; Film Grain is 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.
+19 -8
View File
@@ -1,12 +1,15 @@
# VectorCraft Roadmap
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **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)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -26,7 +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 30/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 |
@@ -35,7 +38,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (50–85 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
@@ -52,7 +55,7 @@ everything is measured from our code against Illustrator's public documentation.
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
| Localization (12 key languages) | 74% (measured) | 50–85 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
@@ -77,7 +80,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| 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 |
@@ -102,14 +105,14 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Hindi | `hi` | none | 0% |
| Arabic | `ar` | none (no right-to-left interface) | 0% |
| French | `fr` | full | 100% |
| Portuguese (Brazil) | `pt-br` | partial | 68% |
| Portuguese (Brazil) | `pt-br` | full | 100% |
| Indonesian | `id` | none | 0% |
| Japanese | `ja` | full | 100% |
| German | `de` | full | 98% |
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | full | 100% |
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -125,11 +128,17 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
| Date | What landed |
|---|---|
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 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. |
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With the Direct Selection tool, double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character; pressing Escape finishes editing and returns to the selection tool used before double-clicking, and in isolation mode the first Escape exits editing while preserving isolation until a second Escape. With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
@@ -168,6 +177,8 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
+18 -3
View File
@@ -4,7 +4,7 @@
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000] [--deep]
@@ -84,7 +84,7 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
@@ -95,6 +95,11 @@ USAGE:
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
draws differently from its PDF part (which can be older), as long as it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
--hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit;
visible), --transparent-images (default flatten, with the art under them; omit) and
--outline-text (type as outlines instead of text).
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -109,7 +114,8 @@ USAGE:
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
budget is exceeded, unless the report says the machine is busy. --deep also times work with
no budget yet and prints memory use (the working set on Windows).
no budget yet and prints memory use (the working set on Windows), with the peak so far on
Windows and Linux.
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
@@ -223,6 +229,9 @@ fn convert(args: &[String]) -> Result<(), String> {
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
(1.0f64, None::<u64>, None::<String>, false, false, false);
let mut decimals = None::<u64>;
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
// `transparentImages`); the engine checks their values.
let mut legacy: Vec<(&str, String)> = vec![];
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
@@ -233,6 +242,9 @@ fn convert(args: &[String]) -> Result<(), String> {
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
"--replace-lossy" => replace_lossy = true,
"--editing-data" => editing_data = true,
"--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)),
"--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)),
"--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)),
f => files.push(f.to_string()),
}
}
@@ -250,6 +262,9 @@ fn convert(args: &[String]) -> Result<(), String> {
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
for (key, value) in legacy {
params[key] = json!(value);
}
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
// much as what writing the output did.
+99 -16
View File
@@ -136,31 +136,63 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
Ok((handle, segment))
}
/// Resident memory of this process in MB (`ps`), if available.
/// Resident memory of this process in MB (`ps`), if available, with the most it has held so far
/// on Linux (`VmHWM` in `/proc/self/status`). The peak is None on macOS, which has no `/proc`.
#[cfg(not(windows))]
fn rss_mb() -> Option<f64> {
fn rss_mb() -> Option<(f64, Option<f64>)> {
let pid = std::process::id().to_string();
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
let peak = std::fs::read_to_string("/proc/self/status").ok().and_then(|status| vm_hwm_mb(&status));
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| (kb / 1024.0, peak))
}
/// The peak resident set size in MB from the `VmHWM` line of a `/proc/<pid>/status` text, which
/// gives it in kB. It is None when the text has no `VmHWM` line in kB.
#[cfg(any(not(windows), test))]
fn vm_hwm_mb(status: &str) -> Option<f64> {
let kb = status.lines().find_map(|line| line.strip_prefix("VmHWM:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
Some(kb / 1024.0)
}
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
/// are in physical memory, the figure `ps` reports as resident memory elsewhere. The same call
/// gives the peak working set, the most the working set has held so far.
#[cfg(windows)]
fn rss_mb() -> Option<f64> {
fn rss_mb() -> Option<(f64, Option<f64>)> {
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
let mb = |bytes: usize| bytes as f64 / (1024.0 * 1024.0);
Some((mb(counters.WorkingSetSize), Some(mb(counters.PeakWorkingSetSize))))
}
/// A tracked row of `--deep`: the work's name and its median in ms. Names are padded to 54
/// characters.
fn tracked_row(name: &str, ms: f64) -> String {
format!(" {name:<54} {ms:>10.2} ms")
}
/// A memory row of `--deep`: the reading's name and value in MB, then the peak so far when
/// there is one. Names are padded to 56 characters.
fn memory_row(name: &str, mb: f64, peak: Option<f64>) -> String {
let peak = peak.map_or_else(String::new, |peak| format!(" peak {peak:>8.0} MB"));
format!(" {name:<56} {mb:>8.0} MB{peak}")
}
/// A row of the budget table: the budget's name, the median and the budget in ms, then "ok" or
/// "OVER". Names are padded to 42 characters, as in the table's header.
fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String {
format!(" {name:<42} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" })
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory, with
/// the peak so far where `rss_mb` reads one.
fn deep(n: usize) -> Result<(), String> {
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut rows: Vec<(String, f64)> = vec![];
let mut mem: Vec<(String, f64)> = vec![];
let mut mem: Vec<(String, f64, Option<f64>)> = vec![];
let mut note = |what: &str| {
if let Some(m) = rss_mb() {
mem.push((what.to_string(), m));
if let Some((m, peak)) = rss_mb() {
mem.push((what.to_string(), m, peak));
}
};
note("start");
@@ -271,14 +303,15 @@ fn deep(n: usize) -> Result<(), String> {
let saved = vectorcraft_format::save_file(&doc);
let reopened = vectorcraft_format::load(&saved).map_err(|e| e.to_string())?;
outln!(" output hash: SVG export {:016x}, save→open→save {:016x}", fnv(svg.as_bytes()), fnv(&vectorcraft_format::save_file(&reopened)));
note("after the SVG export and save→open→save");
outln!(" tracked (no budget yet)");
for (name, ms) in &rows {
outln!(" {name:<52} {ms:>10.2} ms");
outln!("{}", tracked_row(name, *ms));
}
// `rss_mb` reads the working set on Windows.
let memory = if cfg!(windows) { "working set" } else { "memory" };
for (what, mb) in &mem {
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
for (what, mb, peak) in &mem {
outln!("{}", memory_row(&format!("{memory} {what}"), *mb, *peak));
}
Ok(())
}
@@ -381,11 +414,11 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
let mut over = 0;
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
outln!(" {:<42} {:>10} {:>10}", "", "measured", "budget");
for (name, ms, budget) in &rows {
let ok = ms <= budget;
over += usize::from(!ok);
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
outln!("{}", budget_row(name, *ms, *budget, ok));
}
match (over, noisy) {
(0, _) => Ok(()),
@@ -427,7 +460,57 @@ mod tests {
#[cfg(windows)]
#[test]
fn windows_reads_the_working_set() {
let mb = rss_mb();
let mb = rss_mb().map(|(mb, _)| mb);
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
}
/// On Windows and Linux `rss_mb` also gives the peak: after this test writes 64 MB and frees
/// it, the peak is no more than 16 MB below the resident memory read while it held the 64 MB.
#[cfg(any(windows, target_os = "linux"))]
#[test]
fn freed_memory_stays_in_the_peak() {
let buffer = std::hint::black_box(vec![1u8; 64 << 20]);
let held = rss_mb();
drop(buffer);
let (Some((held, _)), Some((now, peak))) = (held, rss_mb()) else {
eprintln!("no memory reading (Linux without `ps`): nothing to check");
return;
};
assert!(peak.is_some_and(|peak| peak > held - 16.0), "holding 64 MB: {held:.1} MB, after freeing it: {now:.1} MB, peak {peak:?}");
}
/// `vm_hwm_mb` reads the peak resident set size from the `VmHWM` line of a status text that
/// also has a `VmRSS` line.
#[test]
fn vm_hwm_is_the_peak_resident_set_size() {
let status = "Name:\tvectorcraft-cli\nVmPeak:\t 2097152 kB\nVmSize:\t 1572864 kB\nVmHWM:\t 819200 kB\nVmRSS:\t 204800 kB\n";
assert_eq!(vm_hwm_mb(status), Some(800.0));
// A text without a `VmHWM` line gives no peak.
assert_eq!(vm_hwm_mb("Name:\tkthreadd\nState:\tS (sleeping)\n"), None);
}
/// A memory row ends with the peak when there is one; without a peak it ends with the value
/// in MB. With the longest name on Windows, the value and the peak line up with the other rows.
#[test]
fn memory_rows_end_with_the_peak_when_there_is_one() {
let row = memory_row("working set start", 346.4, Some(470.2));
assert!(row.starts_with(" working set start ") && row.ends_with(" 346 MB peak 470 MB"), "{row}");
let longest = memory_row("working set after rendering the photos with 3 renderers", 2514.0, Some(2541.0));
assert_eq!(longest.chars().count(), row.chars().count(), "{longest}\n{row}");
let row = memory_row("memory start", 346.4, None);
assert!(row.starts_with(" memory start ") && row.ends_with(" 346 MB") && !row.contains("peak"), "{row}");
}
/// With the longest names, the values of a tracked row and of the budget table's rows line
/// up with the rows that have short names.
#[test]
fn the_longest_tracked_and_budget_names_fit_their_columns() {
let longest = tracked_row("render 4 embedded 24 MP photos with 3 renderers (cold)", 774.61);
let short = tracked_row("Pathfinder crop, 300 paths", 30.94);
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
for name in ["handle drag, 5,000-anchor path (per move)", "segment drag, 5,000-anchor path (per move)"] {
let (longest, short) = (budget_row(name, 0.12, 8.0, true), budget_row("export SVG", 7.81, 500.0, true));
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
}
}
}
+58 -1
View File
@@ -86,7 +86,10 @@ fn help_lists_readable_formats() {
#[test]
fn help_lists_perf_deep() {
let out = ok(&["--help"]);
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
assert!(
out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times") && out.contains("with the peak so far on"),
"{out}"
);
}
/// `info` reports the import warnings: an EPS read only up to an error names it.
@@ -115,3 +118,57 @@ fn convert_reports_import_warnings() {
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
#[test]
fn convert_writes_legacy_ai_with_its_options() {
// Two artboards, a shown rectangle and a hidden one.
let src = tmp("legacy.vectorcraft");
ok(&[
"run",
"--cmd",
"file.new",
"--params",
r#"{"width":120,"height":80,"artboards":2}"#,
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":10,"y":10,"width":20,"height":10}"#,
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":40,"y":10,"width":20,"height":10}"#,
"--cmd",
"object.hide",
"--export",
&src,
]);
// One file per artboard, named after it; hidden objects in a hidden layer by default.
let out = tmp("legacy.ai");
let r: Value = serde_json::from_str(&ok(&["convert", &src, &out, "--ai-version", "7"])).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(files.len(), 2, "{r}");
assert!(files.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS-Adobe-3.0")), "{r}");
assert!(r["warnings"].to_string().contains("hidden layer"), "{r}");
// One artboard is the file itself; the hidden rectangle left out.
let one = tmp("legacy-one.ai");
let r: Value = serde_json::from_str(&ok(&[
"convert",
&src,
&one,
"--ai-version",
"7",
"--range",
"1",
"--hidden-objects",
"omit",
"--transparent-images",
"omit",
]))
.unwrap();
assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}");
assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0"));
// A value the engine doesn't take is an error.
let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
}
+1 -1
View File
@@ -28,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+16 -3
View File
@@ -2,12 +2,25 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="VectorCraft">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="theme-color" content="#262626">
<title>VectorCraft</title>
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#vectorcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626;
overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
/* Inside the safe areas (notch, home indicator) of a full-screen app. */
#vectorcraft_canvas { position: absolute; top: env(safe-area-inset-top, 0); right: env(safe-area-inset-right, 0);
bottom: env(safe-area-inset-bottom, 0); left: env(safe-area-inset-left, 0);
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
height: calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
display: block; touch-action: none; }
#vectorcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
+5 -1
View File
@@ -9,7 +9,9 @@
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
//! `Services::inbox`;
//! - Save / Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
//! hover, palm rejection, fingers navigate once a pen has been seen).
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
@@ -18,6 +20,8 @@
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
mod pencil;
#[cfg(target_arch = "wasm32")]
mod web;
+395
View File
@@ -0,0 +1,395 @@
//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in
//! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from
//! a finger and see each touch twice (as a pointer and as a touch event).
//!
//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the
//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers
//! where the pen can (Apple Pencil hover).
//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first
//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates
//! only once a pen has been seen ([`touch::set_source`]), for this page session only.
//! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers
//! already down (a palm resting first).
//!
//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. Until the first pen
//! contact, fingers and their DOM touch events also stay with eframe (including its text agent).
#[cfg(target_arch = "wasm32")]
use std::cell::RefCell;
#[cfg(target_arch = "wasm32")]
use std::rc::Rc;
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
use vectorcraft_ui_egui::touch::{self, Source};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast as _;
/// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger).
const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1);
const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2);
/// The pen and finger contacts followed, and the egui events they made since the last frame.
#[derive(Default)]
pub struct Pointers {
events: Vec<Event>,
/// The pen pressing, by pointer id.
pen: Option<i32>,
/// The fingers down, by pointer id.
fingers: Vec<i32>,
/// The contact driving egui's pointer.
primary: Option<i32>,
button: Option<PointerButton>,
}
#[cfg(target_arch = "wasm32")]
pub type SharedPointers = Rc<RefCell<Pointers>>;
/// Browser-independent snapshot, also used by the routing regression tests.
struct Input<'a> {
pointer_type: &'a str,
id: i32,
button: i16,
pressure: f32,
modifiers: egui::Modifiers,
}
// Match eframe's button_from_mouse_event, including barrel/right and auxiliary buttons.
fn button(number: i16) -> Option<PointerButton> {
match number {
0 => Some(PointerButton::Primary),
1 => Some(PointerButton::Middle),
2 => Some(PointerButton::Secondary),
3 => Some(PointerButton::Extra1),
4 => Some(PointerButton::Extra2),
_ => None,
}
}
impl Pointers {
/// The events since the last frame, for the frame's raw input.
pub fn take(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force });
}
fn press(&mut self, id: i32, pos: Pos2, button: PointerButton, modifiers: egui::Modifiers) {
self.primary = Some(id);
self.button = Some(button);
self.events.push(Event::ModifiersChanged(modifiers));
self.events.push(Event::PointerMoved(pos));
self.events.push(Event::PointerButton { pos, button, pressed: true, modifiers });
}
fn release(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
self.primary = None;
if let Some(button) = self.button.take() {
self.events.push(Event::ModifiersChanged(modifiers));
self.events.push(Event::PointerButton { pos, button, pressed: false, modifiers });
}
}
/// Cancel every finger down (a palm under a pen landing).
fn cancel_fingers(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
for id in std::mem::take(&mut self.fingers) {
self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None);
if self.primary == Some(id) {
self.release(pos, modifiers);
self.events.push(Event::PointerGone);
}
}
}
/// The paired DOM touch events must follow the same routing as pointer events.
fn captures_touch(&self, on_canvas: bool) -> bool {
touch::pen_seen() && (on_canvas || self.pen.is_some() || !self.fingers.is_empty())
}
/// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe).
fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool {
let id = e.id;
match e.pointer_type {
"pen" => {
let force = Some(e.pressure.clamp(0.0, 1.0));
match kind {
"pointerdown" => {
if !on_canvas && self.pen.is_none() {
return false;
}
let Some(button) = button(e.button) else { return false };
touch::set_source(Source::Pen);
self.cancel_fingers(pos, e.modifiers);
self.pen = Some(id);
self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force);
self.press(id, pos, button, e.modifiers);
}
"pointermove" if self.pen == Some(id) => {
// The positions the pen reported between two events (up to 240 a second
// on an iPad), each with its pressure, so fast strokes keep their curve.
for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) {
self.touch(PEN_DEVICE, id, TouchPhase::Move, c.0, Some(c.1));
self.events.push(Event::PointerMoved(c.0));
}
self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force);
self.events.push(Event::PointerMoved(pos));
}
// Hovering (Apple Pencil hover): the pointer follows without pressing.
"pointermove" => {
if self.primary.is_some() {
return true;
}
if !on_canvas {
return false;
}
self.events.push(Event::PointerMoved(pos));
}
"pointerup" | "pointercancel" if self.pen == Some(id) => {
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
self.pen = None;
self.touch(PEN_DEVICE, id, phase, pos, None);
self.release(pos, e.modifiers);
}
// A pen leaving the hover range, or an event of a press we didn't take.
_ => {
if kind == "pointerleave" && self.primary.is_none() && on_canvas {
self.events.push(Event::PointerGone);
}
return self.pen.is_some();
}
}
true
}
// Keep eframe's touch/IME path unchanged on touch-only devices. Reloading the
// page resets this policy; an old localStorage marker is deliberately ignored.
"touch" if !touch::pen_seen() => false,
"touch" => {
match kind {
"pointerdown" => {
// Palm rejection: nothing presses while the pen does.
if self.pen.is_some() {
return true;
}
if !on_canvas && self.fingers.is_empty() {
return false;
}
self.fingers.push(id);
self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None);
if self.primary.is_none() && self.fingers.len() == 1 {
touch::set_source(Source::Finger);
self.press(id, pos, PointerButton::Primary, e.modifiers);
}
}
"pointermove" if self.fingers.contains(&id) => {
self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None);
if self.primary == Some(id) {
self.events.push(Event::PointerMoved(pos));
}
}
"pointerup" | "pointercancel" if self.fingers.contains(&id) => {
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
self.fingers.retain(|f| *f != id);
self.touch(FINGER_DEVICE, id, phase, pos, None);
if self.primary == Some(id) {
self.release(pos, e.modifiers);
self.events.push(Event::PointerGone);
}
}
// A rejected palm's later events, or one we never took.
_ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas,
}
true
}
"mouse" => {
if kind == "pointerdown" {
touch::set_source(Source::Mouse);
}
false
}
_ => false,
}
}
}
/// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch
/// events that follow them), for the runner's frames. Mouse events stay eframe's.
#[cfg(target_arch = "wasm32")]
pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> {
let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into();
let options = web_sys::AddEventListenerOptions::new();
options.set_capture(true);
options.set_passive(false);
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
let (pointers, canvas) = (pointers.clone(), canvas.clone());
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| {
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
let rect = canvas.get_bounding_client_rect();
let z = runner.egui_ctx().zoom_factor();
let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z);
let at = |x: i32, y: i32| egui::pos2((x as f32 - rect.left() as f32) / z, (y as f32 - rect.top() as f32) / z);
let coalesced: Vec<(Pos2, f32)> = if kind == "pointermove"
&& e.pointer_type() == "pen"
// Safari before 17 hasn't it, and calling a missing method would abort the app.
&& js_sys::Reflect::has(&e, &"getCoalescedEvents".into()).unwrap_or(false)
{
e.get_coalesced_events()
.iter()
.filter_map(|c| c.dyn_into::<web_sys::PointerEvent>().ok())
.map(|c| (at(c.client_x(), c.client_y()), c.pressure().clamp(0.0, 1.0)))
.collect()
} else {
vec![]
};
let pointer_type = e.pointer_type();
let input = Input {
pointer_type: &pointer_type,
id: e.pointer_id(),
button: e.button(),
pressure: e.pressure(),
// Same semantics as eframe's modifiers_from_mouse_event.
modifiers: egui::Modifiers {
alt: e.alt_key(),
ctrl: e.ctrl_key(),
shift: e.shift_key(),
mac_cmd: e.meta_key(),
command: e.ctrl_key() || e.meta_key(),
},
};
if !pointers.borrow_mut().feed(kind, &input, pos, &coalesced, on_canvas) {
return;
}
e.stop_immediate_propagation();
// No compatibility mouse events, no text selection or magnifier.
e.prevent_default();
if kind == "pointerup" {
// Keys typed next (a Magic Keyboard) go to the app.
canvas.focus().ok();
// Safari lets a page open the share sheet, a download or the on-screen keyboard
// only during the user's gesture: handle the release now.
runner.logic();
}
runner.egui_ctx().request_repaint();
})?;
}
// The touch events behind those pointer events: eframe would see each touch a second time,
// and Safari would scroll, zoom or select.
for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] {
let (pointers, canvas) = (pointers.clone(), canvas.clone());
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| {
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
let p = pointers.borrow();
if p.captures_touch(on_canvas) {
e.stop_immediate_propagation();
if e.cancelable() {
e.prevent_default();
}
}
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn input(pointer_type: &str, button: i16) -> Input<'_> {
Input { pointer_type, id: 7, button, pressure: 0.6, modifiers: egui::Modifiers::default() }
}
fn reset() {
touch::set_pen_seen(false);
touch::set_source(Source::Mouse);
}
#[test]
fn pen_buttons_and_modifiers_survive_press_release_and_cancel() {
for (number, expected) in [
(0, PointerButton::Primary),
(1, PointerButton::Middle),
(2, PointerButton::Secondary),
(3, PointerButton::Extra1),
(4, PointerButton::Extra2),
] {
for end in ["pointerup", "pointercancel"] {
reset();
let mut p = Pointers::default();
let mut e = input("pen", number);
e.modifiers = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true };
let pos = Pos2::new(30.0, 40.0);
assert!(p.feed("pointerdown", &e, pos, &[], true));
assert!(p.feed(end, &e, pos, &[], false));
let buttons: Vec<_> = p
.take()
.into_iter()
.filter_map(|e| match e {
Event::PointerButton { button, pressed, modifiers, .. } => Some((button, pressed, modifiers)),
_ => None,
})
.collect();
assert_eq!(buttons, [(expected, true, e.modifiers), (expected, false, e.modifiers)]);
assert!(p.pen.is_none() && p.primary.is_none());
}
}
reset();
}
#[test]
fn touch_only_and_mouse_input_stay_with_eframe_and_session_can_reset() {
reset();
let mut p = Pointers::default();
let pos = Pos2::new(10.0, 20.0);
for pointer_type in ["touch", "mouse"] {
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
assert!(!p.feed(kind, &input(pointer_type, 0), pos, &[], true));
}
}
assert!(p.take().is_empty());
assert!(!p.captures_touch(true), "eframe must also receive DOM touch events before a pen");
assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true));
assert!(p.feed("pointerup", &input("pen", 0), pos, &[], true));
assert!(p.captures_touch(true));
assert!(p.feed("pointerdown", &input("touch", 0), pos, &[], true));
assert!(p.feed("pointerup", &input("touch", 0), pos, &[], true));
// A new page starts from false, without reading any old persistent marker.
reset();
let mut next_page = Pointers::default();
assert!(!next_page.feed("pointerdown", &input("touch", 0), pos, &[], true));
assert!(next_page.take().is_empty());
assert!(!next_page.captures_touch(true));
}
#[test]
fn finger_modifiers_and_palm_cancellation_are_preserved() {
reset();
touch::set_source(Source::Pen);
let mut p = Pointers::default();
let pos = Pos2::new(10.0, 20.0);
let mut finger = input("touch", 0);
finger.id = 10;
finger.modifiers.shift = true;
assert!(p.feed("pointerdown", &finger, pos, &[], true));
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: true, modifiers, .. } if modifiers.shift)));
let pen = input("pen", 0);
assert!(p.feed("pointerdown", &pen, pos, &[], true));
assert!(p.take().iter().any(|e| matches!(e, Event::Touch { phase: TouchPhase::Cancel, .. })));
assert!(p.fingers.is_empty());
assert!(p.feed("pointermove", &finger, pos, &[], true));
assert!(p.take().is_empty(), "a resting palm cannot move the pen");
assert!(p.feed("pointerup", &pen, pos, &[], true));
assert!(p.feed("pointerdown", &finger, pos, &[], true));
assert!(p.feed("pointerup", &finger, pos, &[], true));
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift)));
reset();
}
#[test]
fn unknown_pen_buttons_are_not_reinterpreted_as_primary() {
reset();
let mut p = Pointers::default();
assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true));
assert!(p.take().is_empty());
assert!(!touch::pen_seen());
}
}
+48 -1
View File
@@ -17,6 +17,7 @@ use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
use crate::locks::WebLocks;
use crate::pencil::SharedPointers;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
@@ -47,6 +48,8 @@ struct Host {
place_inbox: PlaceInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
pointers: SharedPointers,
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
/// restart keeps them, so the tab stays the owner of its recovery area.
locks: Option<WebLocks>,
@@ -73,6 +76,7 @@ pub fn start() {
place_inbox: Arc::default(),
drag: DragPos::default(),
pasted: Rc::default(),
pointers: SharedPointers::default(),
locks,
restarts: 0,
};
@@ -96,6 +100,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
// The app until the new runner takes it (still here if starting fails before).
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
let (pointers, pen_canvas) = (host.pointers.clone(), canvas.clone());
let result = runner
.start(
canvas,
@@ -127,6 +132,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
.await;
match (result, kept) {
(Ok(()), _) => {
if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) {
log::error!("vectorcraft-web: no pen input: {}", js_err(e));
}
if let Some(el) = element(LOADING_ID) {
el.remove();
}
@@ -310,7 +318,7 @@ fn notice(lines: &[&str]) {
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
@@ -381,6 +389,7 @@ impl eframe::App for WebShell {
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
raw.events.extend(self.host.pointers.borrow_mut().take());
if let Some(app) = &mut self.app {
app.raw_input_hook(raw);
}
@@ -397,6 +406,7 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
let screen_color = eye_dropper(ctx.clone());
Services {
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
@@ -434,6 +444,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
ctx.request_repaint();
});
})),
// Sample Color from Screen: the browser's EyeDropper, where it has one.
screen_color,
// A download that couldn't be started fails the save or export it is for (#1693).
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
inbox: Some(inbox),
@@ -444,6 +456,41 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
}
}
/// Sample Color from Screen in the browser: the EyeDropper API
/// (<https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper>), which only some browsers have,
/// on secure (HTTPS) pages; `None` without it, and the command is then gray. `open()` needs a
/// transient user activation (otherwise it fails with a `NotAllowedError`) and resolves with the
/// picked color's `sRGBHex`; Escape rejects it with an `AbortError`.
fn eye_dropper(ctx: egui::Context) -> Option<vectorcraft_ui_egui::screen_color::StartSampler> {
use js_sys::{Function, Promise, Reflect};
use wasm_bindgen::JsValue;
let window = web_sys::window()?;
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
Some(Box::new(move || {
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(js_err)?;
let open = Reflect::get(&eye, &JsValue::from_str("open")).map_err(js_err)?.dyn_into::<Function>().map_err(js_err)?;
let promise = open.call0(&eye).map_err(js_err)?.dyn_into::<Promise>().map_err(js_err)?;
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let answer = match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(result) => Reflect::get(&result, &JsValue::from_str("sRGBHex"))
.ok()
.and_then(|hex| hex.as_string())
.and_then(|hex| vectorcraft_ui_egui::screen_color::srgb_from_hex(&hex))
.map(Some)
.ok_or_else(|| "the browser's EyeDropper answered no color".to_string()),
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|n| n.as_string()).as_deref() == Some("AbortError") => Ok(None),
Err(e) => Err(js_err(e)),
};
// The UI may be gone (the page closing): nothing waits for the color then.
let _ = tx.send(answer);
ctx.request_repaint();
});
Ok(rx)
}))
}
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock, and with a Web
+1 -1
View File
@@ -55,7 +55,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
+538 -12
View File
@@ -37,9 +37,23 @@
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
//! the app again without that adapter in the same way.
//!
//! A driver can also crash the whole process, and then no code in it runs to start it again:
//! Intel's Direct3D 12 driver (`igd12umd64.dll`) ended 0.9.0 before its window appeared (#2243).
//! So each start writes [`START_FILE`] in the log folder before the graphics start, adds the
//! adapter once one is picked, and clears both once the first frame reaches the screen or the app
//! ends without crashing ([`Startup::track`]). The next start that finds a start still recorded
//! there leaves that adapter out, as a restart does, and keeps leaving it out; when the crash came
//! while the adapters were being listed, before one was picked, it lists OpenGL's adapters alone
//! (except on macOS, where wgpu has none). A start that `WGPU_BACKEND` or `WGPU_ADAPTER_NAME`
//! chooses leaves nothing out, and the crashes are forgotten when the app version or
//! Preferences › Performance › Graphics Processor changes, when a start shows its first frame on
//! the adapter that crashed, or when no adapter is left to show the window.
use std::io::{Read as _, Seek as _, Write as _};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
@@ -50,6 +64,14 @@ use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
/// again.
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
/// The file in the log folder that records each start of the window until its first frame
/// reaches the screen, and the adapters earlier starts crashed on ([`Startup::track`]).
pub const START_FILE: &str = "graphics-start.json";
/// The most bytes of [`START_FILE`] read, and the most crashed adapters it keeps.
const START_FILE_LIMIT: u64 = 64 * 1024;
const CRASHED_LIMIT: usize = 16;
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
const NAME_ENV: &str = "WGPU_ADAPTER_NAME";
@@ -348,6 +370,226 @@ fn parse_skip(v: &str) -> Vec<String> {
v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect()
}
/// Whether `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` is set: then a start uses the backends and the
/// adapter they name, and leaves out none that earlier starts crashed on.
pub fn explicit_choice() -> bool {
std::env::var_os("WGPU_BACKEND").is_some() || std::env::var_os(NAME_ENV).is_some()
}
/// How the last start ended when it ended before the window's first frame reached the screen,
/// with no error or panic to report: a crash in the graphics driver (#2243), or the process was
/// ended from outside.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Crash {
/// While the adapters were being listed, before one was picked.
Listing,
/// On the adapter with this [`key`] and name.
Adapter { key: String, name: String },
}
/// What a start leaves out because earlier starts crashed ([`Startup::track`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LeftOut {
/// The adapters ([`key`]s) that crashed a start.
pub adapters: Vec<String>,
/// A start crashed while listing the adapters: list OpenGL's alone. Never on macOS, where wgpu
/// has no OpenGL adapters to fall back to.
pub opengl_only: bool,
/// The crash the last start ended in, when this start leaves something out for it.
pub crash: Option<Crash>,
}
impl LeftOut {
/// `backends` to list: OpenGL alone after a crash while listing, when it is among them;
/// otherwise `backends`.
pub fn backends(&self, backends: Backends) -> Backends {
if self.opengl_only && backends.contains(Backends::GL) { Backends::GL } else { backends }
}
}
/// An adapter in [`START_FILE`]: its [`key`] and name.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct Recorded {
key: String,
name: String,
}
/// The start in progress, in [`START_FILE`].
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct Starting {
/// The adapter picked; `None` while the adapters are being listed.
adapter: Option<Recorded>,
/// The listing found an adapter of a backend other than OpenGL.
native: bool,
}
/// What [`START_FILE`] holds.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct StartRecord {
/// The app version and the Graphics Processor preference (`gpuPreference`) it was written
/// under: the crashes count only for the same ones.
version: String,
preference: String,
/// The start in progress, from before the graphics start until its first frame reaches the
/// screen or the app ends without crashing.
starting: Option<Starting>,
/// The adapters that crashed a start, oldest first.
crashed: Vec<Recorded>,
/// A start crashed while listing the adapters.
listing_crashed: bool,
}
impl StartRecord {
/// This start's record, made from the one the last start left (`previous`; `None` when there
/// is none or it can't be read), and the crash that start ended in, if it did. A record
/// written by another version or under another `preference` is dropped.
fn begin(previous: Option<Self>, version: &str, preference: &str) -> (Self, Option<Crash>) {
let mut record = previous.filter(|r| r.version == version && r.preference == preference).unwrap_or_default();
let crash = record.starting.take().map(|s| match s.adapter {
Some(Recorded { key, name }) => Crash::Adapter { key, name },
None => Crash::Listing,
});
match &crash {
Some(Crash::Adapter { key, name }) if !record.crashed.iter().any(|c| &c.key == key) => {
record.crashed.push(Recorded { key: key.clone(), name: name.clone() });
if record.crashed.len() > CRASHED_LIMIT {
record.crashed.remove(0);
}
}
Some(Crash::Listing) => record.listing_crashed = true,
_ => {}
}
record.version = version.to_string();
record.preference = preference.to_string();
record.starting = Some(Starting::default());
(record, crash)
}
/// What this start leaves out: nothing when `explicit` ([`explicit_choice`]). `crash` is the
/// one [`begin`](Self::begin) found.
fn left_out(&self, crash: Option<Crash>, explicit: bool) -> LeftOut {
if explicit {
return LeftOut::default();
}
let opengl_only = self.listing_crashed && !cfg!(target_os = "macos");
let crash = crash.filter(|c| matches!(c, Crash::Adapter { .. }) || opengl_only);
LeftOut { adapters: self.crashed.iter().map(|c| c.key.clone()).collect(), opengl_only, crash }
}
/// The adapter this start picked, after a listing that found one of a backend other than
/// OpenGL when `native`.
fn picked(&mut self, key: &str, name: &str, native: bool) {
self.starting = Some(Starting { adapter: Some(Recorded { key: key.to_string(), name: name.trim().to_string() }), native });
}
/// The listing found no adapter that can show the window: nothing is left to leave out, so the
/// crashes are forgotten and the next start tries every adapter again.
fn none_left(&mut self) {
self.crashed.clear();
self.listing_crashed = false;
}
/// The window's first frame reached the screen: the adapter it was drawn on, and the listing
/// when it found an adapter of a backend other than OpenGL, no longer count as crashed.
fn presented(&mut self) {
let Some(starting) = self.starting.take() else { return };
if let Some(adapter) = starting.adapter {
self.crashed.retain(|c| c.key != adapter.key);
}
if starting.native {
self.listing_crashed = false;
}
}
/// The start ended without crashing: a normal exit, an error, or a restart on another adapter.
fn ended(&mut self) {
self.starting = None;
}
fn to_json(&self) -> String {
let recorded = |r: &Recorded| serde_json::json!({ "key": r.key, "name": r.name });
let mut v = serde_json::json!({
"version": self.version,
"gpuPreference": self.preference,
"crashed": self.crashed.iter().map(recorded).collect::<Vec<_>>(),
"listingCrashed": self.listing_crashed,
});
if let (Some(s), Some(map)) = (&self.starting, v.as_object_mut()) {
map.insert("starting".into(), serde_json::json!({ "adapter": s.adapter.as_ref().map(recorded), "native": s.native }));
}
v.to_string()
}
/// The record in `text`; `None` when it isn't one.
fn parse(text: &str) -> Option<Self> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let recorded = |v: &serde_json::Value| -> Option<Recorded> {
Some(Recorded { key: v.get("key")?.as_str()?.to_string(), name: v.get("name")?.as_str()?.to_string() })
};
let starting = match v.get("starting") {
None => None,
Some(s) => Some(Starting {
adapter: match s.get("adapter") {
None | Some(serde_json::Value::Null) => None,
Some(a) => Some(recorded(a)?),
},
native: s.get("native")?.as_bool()?,
}),
};
let crashed = v.get("crashed")?.as_array()?;
Some(Self {
version: v.get("version")?.as_str()?.to_string(),
preference: v.get("gpuPreference")?.as_str()?.to_string(),
starting,
crashed: crashed.iter().take(CRASHED_LIMIT).map(recorded).collect::<Option<_>>()?,
listing_crashed: v.get("listingCrashed")?.as_bool()?,
})
}
}
/// [`START_FILE`], open and locked while the start runs, so that a second copy of the app starting
/// at the same time doesn't take it for the record of a start that crashed.
struct StartFile {
file: std::fs::File,
record: StartRecord,
}
impl StartFile {
/// Open `dir`'s [`START_FILE`], with the record the last start left in it (`None` when there is
/// none or it can't be read). `Ok(None)` when another copy of the app holds it.
fn open(dir: &Path) -> std::io::Result<Option<(std::fs::File, Option<StartRecord>)>> {
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(START_FILE))?;
match file.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
Err(std::fs::TryLockError::Error(e)) => return Err(e),
}
let mut text = String::new();
// Text that isn't UTF-8, or is longer than any record, is ignored like a damaged record.
let previous = (&file).take(START_FILE_LIMIT).read_to_string(&mut text).ok().and_then(|_| StartRecord::parse(&text));
Ok(Some((file, previous)))
}
fn write(&mut self) {
let text = self.record.to_json();
let written = self.file.set_len(0).and_then(|()| self.file.rewind()).and_then(|()| self.file.write_all(text.as_bytes()));
if let Err(e) = written {
log::warn!("{START_FILE}: {e}");
}
}
}
/// The value of `gpuPreference` the crashes are recorded under: any value this version doesn't
/// know reads as `automatic`, as in [`power_preference`].
fn preference_name(pref: Option<&str>) -> &'static str {
match pref {
Some("highPerformance") => "highPerformance",
Some("lowPower") => "lowPower",
_ => "automatic",
}
}
/// What the window's start-up leaves for [`finish`].
#[derive(Default)]
pub struct Startup {
@@ -357,6 +599,8 @@ pub struct Startup {
ui: OnceLock<egui::Context>,
/// When the app was created, the files named at the start opened: the first frame is due.
ready: OnceLock<Instant>,
/// [`START_FILE`] while the start runs ([`Self::track`]).
start: Mutex<Option<StartFile>>,
}
impl Startup {
@@ -371,6 +615,82 @@ impl Startup {
// Set once, like `created`.
let _ = self.ready.set(Instant::now());
}
/// Record this start in `dir`'s [`START_FILE`] until the window's first frame reaches the
/// screen or the app ends without crashing, and say what it leaves out for the starts that
/// crashed before (see the module docs). `preference` is Preferences › Performance › Graphics
/// Processor, and `explicit` is [`explicit_choice`]. When the file can't be used (it can't be
/// written, or another copy of the app is starting), nothing is recorded or left out.
pub fn track(&self, dir: &Path, preference: Option<&str>, explicit: bool) -> LeftOut {
let (file, previous) = match StartFile::open(dir) {
Ok(Some(opened)) => opened,
Ok(None) => {
log::info!("{START_FILE}: another VectorCraft is starting, so this start isn't recorded");
return LeftOut::default();
}
Err(e) => {
log::warn!("{START_FILE}: {e}");
return LeftOut::default();
}
};
let (record, crash) = StartRecord::begin(previous, env!("CARGO_PKG_VERSION"), preference_name(preference));
let left_out = record.left_out(crash.clone(), explicit);
match &crash {
Some(Crash::Adapter { key, name }) => {
log::warn!("the last start ended on graphics adapter {name} ({key}) before the window's first frame reached the screen")
}
Some(Crash::Listing) => log::warn!("the last start ended while it listed the graphics adapters"),
None => {}
}
if explicit && (!record.crashed.is_empty() || record.listing_crashed) {
log::info!("WGPU_BACKEND or WGPU_ADAPTER_NAME is set: no adapter is left out for the starts that crashed");
}
if !left_out.adapters.is_empty() {
log::warn!("leaving out the graphics adapters earlier starts crashed on: {}", left_out.adapters.join(", "));
}
if left_out.opengl_only {
log::warn!("an earlier start crashed while it listed the graphics adapters: listing OpenGL's alone");
}
let mut file = StartFile { file, record };
file.write();
*self.start.lock().unwrap_or_else(PoisonError::into_inner) = Some(file);
left_out
}
/// [`selector`] picked the adapter with this [`key`] and name; `native` when the listing found
/// an adapter of a backend other than OpenGL.
fn picked(&self, key: &str, name: &str, native: bool) {
// Set once; eframe picks the adapter once.
let _ = self.adapter.set(key.to_string());
self.update(|r| r.picked(key, name, native), false);
}
/// [`selector`] found no adapter that can show the window.
fn none_left(&self) {
self.update(StartRecord::none_left, false);
}
/// The window's first frame reached the screen, or the window started minimized.
fn presented(&self) {
self.update(StartRecord::presented, true);
}
/// The app ends, or starts again, without crashing.
fn ended(&self) {
self.update(StartRecord::ended, true);
}
/// Change the record and write it; `last` releases the file for the next start.
fn update(&self, change: impl FnOnce(&mut StartRecord), last: bool) {
let mut start = self.start.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(file) = start.as_mut() {
change(&mut file.record);
file.write();
}
if last {
*start = None;
}
}
}
/// What [`watch_first_frame`] makes of the window's start.
@@ -396,10 +716,10 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
}
}
/// Watch the window's first frame from another thread: when it hasn't reached the screen
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
/// thread is stuck in the graphics driver.
/// Watch the window's first frame from another thread: once it reaches the screen, clear the start
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was
/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for
/// one that failed, and end this process, whose UI thread is stuck in the graphics driver.
pub fn watch_first_frame(startup: Arc<Startup>) {
let watch = move || loop {
std::thread::sleep(Duration::from_millis(250));
@@ -409,7 +729,10 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
match first_frame(frames, due.elapsed(), minimized) {
FirstFrame::Wait => continue,
FirstFrame::Shown => return,
FirstFrame::Shown => {
startup.presented();
return;
}
FirstFrame::Hung => {}
}
let mut skip = skipped();
@@ -418,6 +741,8 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
FIRST_FRAME_TIMEOUT.as_secs()
);
// The new app records its own start.
startup.ended();
skip.push(failed.to_string());
match restart(&skip.join(",")) {
Ok(()) => std::process::exit(0),
@@ -433,20 +758,22 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPU the `compositor`
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart
/// left out. Every adapter, the displays and the choice are logged: which GPU draws is the first
/// question in every black-window report.
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME`, the adapters a restart
/// left out and those `crashed` ([`LeftOut::adapters`]). Every adapter, the displays and the
/// choice are logged: which GPU draws is the first question in every black-window report.
pub fn selector(
power: PowerPreference,
displays: Vec<DisplayGpu>,
compositor: Option<(u32, u32)>,
crashed: Vec<String>,
startup: Arc<Startup>,
) -> NativeAdapterSelectorMethod {
let named = std::env::var(NAME_ENV).ok();
let skip = skipped();
let left_out: Vec<String> = skip.iter().chain(&crashed).cloned().collect();
Arc::new(move |adapters, surface| {
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &skip);
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &left_out);
let listed: Vec<String> = candidates
.iter()
.map(|c| {
@@ -455,6 +782,8 @@ pub fn selector(
", can't show the window"
} else if skip.contains(&c.key) {
", failed before"
} else if crashed.contains(&c.key) {
", crashed an earlier start"
} else if c.composites(compositor) {
", the compositor draws with it"
} else if drives_primary {
@@ -470,6 +799,7 @@ pub fn selector(
log::info!("graphics adapters: {}", listed.join("; "));
let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i)));
let Some((adapter, chosen)) = first else {
startup.none_left();
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
};
let why = match chosen.drives(&displays) {
@@ -480,8 +810,7 @@ pub fn selector(
(false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"),
};
log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type);
// Set once; eframe picks the adapter once.
let _ = startup.adapter.set(chosen.key.clone());
startup.picked(&chosen.key, &chosen.name, candidates.iter().any(|c| c.backend != Backend::Gl));
Ok(adapter.clone())
})
}
@@ -514,6 +843,8 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st
/// docs), otherwise why it failed. `outcome` is eframe's result, or the message of a panic that
/// ended it.
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
// Whatever follows, this start didn't crash; a new app records its own start.
startup.ended();
let (why, graphics_failed) = match outcome {
Ok(Ok(())) => return Ok(()),
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
@@ -1101,4 +1432,199 @@ mod tests {
GRAPHICS_PANIC.store(false, Ordering::Relaxed);
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
}
/// A log folder of its own for each test.
fn start_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
/// What a start in `dir` leaves out; it then ends without crashing.
fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut {
let s = Startup::default();
let left_out = s.track(dir, None, explicit);
s.ended();
left_out
}
/// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and
/// Windows' software adapter.
fn issue_2243() -> Vec<Candidate> {
vec![
pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)),
pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)),
pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)),
]
}
/// #2243: the driver ends the process after the adapter was picked, before the first frame.
/// The next start leaves that adapter out and draws through OpenGL, and so do the starts after
/// it, without a status-bar note.
#[test]
fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
let dir = start_dir("crash");
let c = issue_2243();
let first = Startup::default();
assert_eq!(first.track(&dir, None, false), LeftOut::default());
first.picked(&c[0].key, &c[0].name, true);
// The process ends here: its file is closed and unlocked, and the start is still recorded.
drop(first);
let second = Startup::default();
let left_out = second.track(&dir, Some("automatic"), false);
assert_eq!(left_out.adapters, ["Dx12:8086:3184"]);
assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() }));
assert!(!left_out.opengl_only);
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL);
let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters);
assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter");
second.picked(&c[2].key, &c[2].name, true);
second.presented();
let third = Startup::default();
let left_out = third.track(&dir, None, false);
assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept");
assert_eq!(left_out.crash, None, "the note is shown once");
third.ended();
let _ = std::fs::remove_dir_all(&dir);
}
/// A start whose first frame reached the screen, or that ended without crashing (a normal exit,
/// an error, a restart on another adapter), leaves nothing out of the next.
#[test]
fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
let dir = start_dir("shown");
let c = issue_2243();
for end in [Startup::presented as fn(&Startup), Startup::ended] {
let first = Startup::default();
first.track(&dir, None, false);
first.picked(&c[0].key, &c[0].name, true);
end(&first);
drop(first);
assert_eq!(next_start(&dir, false), LeftOut::default());
}
// So does a start that ended before it picked an adapter.
let first = Startup::default();
first.track(&dir, None, false);
assert!(finish(Ok(Ok(())), &first).is_ok());
drop(first);
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a
/// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on
/// macOS, where wgpu has none.
#[test]
fn a_crash_while_listing_lists_opengl_alone() {
let dir = start_dir("listing");
let first = Startup::default();
first.track(&dir, None, false);
drop(first);
let second = Startup::default();
let left_out = second.track(&dir, None, false);
assert!(left_out.adapters.is_empty());
if cfg!(target_os = "macos") {
assert_eq!(left_out, LeftOut::default());
} else {
assert_eq!(left_out.crash, Some(Crash::Listing));
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL);
assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL);
assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to");
}
// Listing OpenGL alone found nothing that can show the window: the next start lists every
// backend again.
second.none_left();
second.ended();
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that
/// start shows its first frame on the adapter that crashed, the adapter counts again.
#[test]
fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
let dir = start_dir("explicit");
let c = issue_2243();
let first = Startup::default();
first.track(&dir, None, false);
first.picked(&c[0].key, &c[0].name, true);
drop(first);
assert_eq!(next_start(&dir, true), LeftOut::default());
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them");
let explicit = Startup::default();
explicit.track(&dir, None, true);
explicit.picked(&c[0].key, &c[0].name, true);
explicit.presented();
assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window");
let _ = std::fs::remove_dir_all(&dir);
}
/// A damaged record, a file that can't be opened and one another starting copy of the app holds
/// are ignored: the start goes on with nothing left out.
#[test]
fn a_damaged_or_unreadable_start_file_is_ignored() {
let dir = start_dir("damaged");
std::fs::create_dir_all(&dir).unwrap();
let damaged: [&[u8]; 9] = [
b"",
b"{",
b"[]",
b"null",
b"{\"version\":1}",
b"{\"starting\":{}}",
b"\xef\xbb\xbf\0",
b"\xff\xfe{",
&[b' '; START_FILE_LIMIT as usize + 1],
];
for text in damaged {
std::fs::write(dir.join(START_FILE), text).unwrap();
assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)]));
}
// Another copy of the app is starting: this start neither reads nor writes the file.
let c = issue_2243();
let starting = Startup::default();
starting.track(&dir, None, false);
starting.picked(&c[0].key, &c[0].name, true);
assert_eq!(next_start(&dir, false), LeftOut::default());
drop(starting);
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]);
// A folder in the file's place.
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(START_FILE)).unwrap();
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true };
let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic");
assert_eq!(crash, None);
record.starting = Some(starting("Dx12:8086:3184"));
let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic");
assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() }));
assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]);
// Written and read back.
assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone()));
assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty());
assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty());
assert_eq!(preference_name(None), "automatic");
assert_eq!(preference_name(Some("powerSaving")), "automatic");
assert_eq!(preference_name(Some("lowPower")), "lowPower");
// An adapter is listed once, and the list is bounded.
let mut record = record;
for n in 0..CRASHED_LIMIT + 4 {
for _ in 0..2 {
record.starting = Some(starting(&format!("Dx12:10de:{n:04x}")));
record = StartRecord::begin(Some(record), "0.9.0", "automatic").0;
}
}
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
}
}
+69
View File
@@ -0,0 +1,69 @@
//! macOS: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the system's color
//! sampler, `NSColorSampler` (macOS 10.15 and later), which "displays the system’s color-sampling
//! interface and returns the selected color to your app"
//! (<https://developer.apple.com/documentation/appkit/nscolorsampler>). AppKit calls the handler on
//! the main thread with the color the person clicked, or with nil when they pressed Escape. The
//! color is converted to sRGB before it is sent to the UI; AppKit clips a color outside sRGB to it
//! (Display P3 red arrives as sRGB red).
//!
//! `showSamplerWithSelectionHandler:` has no safe binding; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::channel;
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSColor, NSColorSampler, NSColorSpace};
use vectorcraft_ui_egui::screen_color::{Answer, StartSampler};
/// The system's color sampler; `ui` is woken when it answers.
pub fn sampler(ui: egui::Context) -> StartSampler {
Box::new(move || {
MainThreadMarker::new().ok_or("the color sampler can only be shown from the main thread")?;
let (tx, rx) = channel();
let ui = ui.clone();
let handler = RcBlock::new(move |color: *mut NSColor| {
// SAFETY: AppKit passes the selected color, or nil when the person canceled, and keeps
// it alive while the handler runs.
let answer: Answer = match unsafe { color.as_ref() } {
None => Ok(None),
Some(color) => srgb(color).map(Some).ok_or_else(|| "the sampled color has no sRGB value".into()),
};
// The UI may be gone (the app quitting): nothing waits for the color then.
let _ = tx.send(answer);
ui.request_repaint();
});
let sampler = NSColorSampler::new();
// SAFETY: on the main thread (checked above); the handler has the signature AppKit calls it
// with, and AppKit keeps the block and the sampler until the session ends.
unsafe { sampler.showSamplerWithSelectionHandler(&handler) };
Ok(rx)
})
}
/// The components of `color` in sRGB, or `None` when it has no RGB form (a pattern color).
fn srgb(color: &NSColor) -> Option<[f32; 3]> {
let c = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
Some([c.redComponent() as f32, c.greenComponent() as f32, c.blueComponent() as f32])
}
#[cfg(test)]
mod tests {
use super::*;
/// A color in another space arrives as its sRGB components.
#[test]
fn sampled_colors_convert_to_srgb() {
let red = NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 0.0, 0.0, 1.0);
assert_eq!(srgb(&red), Some([1.0, 0.0, 0.0]));
// Display P3 (0.6, 0.4, 0.3) lies inside sRGB with other numbers: more red, less green and blue.
let p3 = NSColor::colorWithDisplayP3Red_green_blue_alpha(0.6, 0.4, 0.3, 1.0);
let [r, g, b] = srgb(&p3).unwrap();
assert!(r > 0.61 && g < 0.39 && b < 0.29, "converted, not read as sRGB numbers: {r} {g} {b}");
// A color outside sRGB is clipped to it.
assert_eq!(srgb(&NSColor::colorWithDisplayP3Red_green_blue_alpha(1.0, 0.0, 0.0, 1.0)), Some([1.0, 0.0, 0.0]));
let gray = NSColor::colorWithWhite_alpha(0.5, 1.0);
let [r, g, b] = srgb(&gray).unwrap();
assert!((r - g).abs() < 1e-4 && (g - b).abs() < 1e-4, "{r} {g} {b}");
}
}
+58 -3
View File
@@ -1,7 +1,7 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
//! [--in-window-menus] [files…]`
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
@@ -35,6 +35,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
@@ -504,6 +506,8 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
@@ -535,6 +539,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// Sample Color from Screen: the system's color sampler.
#[cfg(target_os = "macos")]
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
@@ -618,6 +625,26 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
}
}
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
const USAGE: &str = "\
Usage: vectorcraft [options] [files…]
Opens the VectorCraft desktop app with the given files.
Options:
--control <port> Start the localhost JSON-lines control server on <port>
(or VECTORCRAFT_CONTROL_PORT)
--automation-read-root <dir> Confine the files control requests read to <dir>
--automation-write-root <dir> Confine the files control requests write to <dir>
--in-window-menus Keep the menus inside the window on macOS
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
--version Print the version and exit
-h, --help Print this help and exit
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
vectorcraft-cli --help
";
fn main() -> std::process::ExitCode {
// 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.
@@ -643,6 +670,14 @@ fn main() -> std::process::ExitCode {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
// used to open (or hang without a display) instead (#2110).
"--help" | "-h" => {
// Not `print!`, which panics when standard output is closed (`| head -0`):
// there is nothing left to tell then.
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
}
@@ -726,6 +761,16 @@ fn main() -> std::process::ExitCode {
}
#[cfg(feature = "wgpu")]
let startup = std::sync::Arc::new(gpu::Startup::default());
// A driver that crashes the process while the window starts leaves this start recorded in the
// log folder; the next start leaves that adapter out (#2243). Runs without preferences, which
// write no log file, don't record their starts.
#[cfg(feature = "wgpu")]
let left_out = match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => startup.track(&dir, gpu_pref, gpu::explicit_choice()),
None => gpu::LeftOut::default(),
};
#[cfg(feature = "wgpu")]
let crash = left_out.crash.clone();
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
@@ -755,13 +800,14 @@ fn main() -> std::process::ExitCode {
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, startup.clone()));
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, left_out.adapters.clone(), startup.clone()));
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends = gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some());
create.instance_descriptor.backends =
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
@@ -836,6 +882,15 @@ fn main() -> std::process::ExitCode {
app.status(format!(
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
));
} else if let Some(crash) = &crash {
app.status(match crash {
gpu::Crash::Adapter { name, .. } => format!(
"The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
),
gpu::Crash::Listing => format!(
"The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
),
});
}
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
+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);
}
}
+101 -2
View File
@@ -8,6 +8,13 @@
//! same in storytold/photocraft#2401). [`service`] strips the two styles and has Windows
//! recalculate the frame; winit puts them back whenever it changes the window flags (maximize,
//! restore, …), so the returned guard runs every frame.
//!
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included, and
//! the shell reads a caption-less window covering its monitor as a full-screen app (#1977). The
//! guard therefore also trims a parked maximize's rect back to the work area plus its own frame,
//! clear of the taskbar, as PhotoCraft does (storytold/photocraft#3308). winit pins the client
//! area to the work area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
/// Run once per frame; a no-op once the window is borderless.
pub type WindowFrameFn = Box<dyn FnMut()>;
@@ -30,8 +37,15 @@ mod platform {
_ => return None,
};
let hwnd = find_window(target)?;
strip(&hwnd);
Some(Box::new(move || strip(&hwnd)))
reapply(&hwnd);
Some(Box::new(move || reapply(&hwnd)))
}
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
/// maximize stays clear of the taskbar.
fn reapply(hwnd: &HWND) {
strip(hwnd);
keep_clear_of_the_taskbar(hwnd);
}
/// The top-level window with the given raw handle: `EnumWindows` hands out real `HWND`s, so
@@ -65,6 +79,29 @@ mod platform {
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// [`super::clamped_height`] for this frame's window: trim a maximized window whose rect runs
/// under the taskbar back to the work area plus its frame. No-op otherwise.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
}
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some(height) =
super::clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
else {
return;
};
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
}
#[cfg(not(all(windows, feature = "wgpu")))]
@@ -73,3 +110,65 @@ mod platform {
None
}
}
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize border
/// (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not parked but
/// being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it already
/// does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at its maximize
/// place. `monitor` is the monitor's full rect and `work` its work area (the monitor less the
/// taskbar) in the same coordinates. The parked check is against the full monitor, because that is
/// where Windows puts a caption-less maximize; the trim is against the work area. Platform-neutral
/// so it is tested everywhere.
#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))]
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
let (l, t, r, b) = rect;
let (ml, mt, mr, _) = monitor;
let (_, _, _, wb) = work;
let side = ml - l;
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
return None;
}
if b <= wb + side {
return None;
}
Some(wb + side - t)
}
#[cfg(test)]
mod tests {
use super::clamped_height;
#[test]
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
// 1920 × 1080 with a 34 px taskbar: a caption-less maximize covers the whole monitor,
// taskbar included (#1977). Trimmed so the rect ends at the work area plus the frame the
// window was parked with.
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
}
#[test]
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
// What Windows sizes a framed maximize to: the work area plus the frame.
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
}
#[test]
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
// Taskbar on the left: the work area starts further right, but the monitor's bottom is
// still clear, so the rect reaches nothing.
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
}
#[test]
fn a_maximize_being_dragged_away_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
}
}
+63
View File
@@ -0,0 +1,63 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Runs the desktop binary without a display, so a regression fails instead of opening a window.
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
let mut child = Command::new(BIN)
.args(args)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env("VECTORCRAFT_NO_PREFS", "1")
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while child.try_wait().unwrap().is_none() {
if Instant::now() >= deadline {
child.kill().unwrap();
panic!("{args:?}: the app started instead of printing its usage");
}
std::thread::sleep(Duration::from_millis(10));
}
child.wait_with_output().unwrap()
}
fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
let out = run(args, env);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}");
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] {
assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}");
}
// Headless work is the CLI's: the usage says where to go.
assert!(stdout.contains("vectorcraft-cli"), "{args:?}: {stdout}");
}
#[test]
fn help_prints_usage_and_exits_before_startup() {
prints_usage(&["--help"], &[]);
prints_usage(&["-h"], &[]);
// After a file or a flag, too: nothing is opened.
prints_usage(&["drawing.svg", "--help"], &[]);
prints_usage(&["--in-window-menus", "-h"], &[]);
}
#[test]
fn help_wins_over_a_bad_control_port_variable() {
prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5v14M20 5v14"/><path d="M4 12h16"/><path d="M7.5 9 4.5 12l3 3M16.5 9l3 3-3 3"/></svg>

After

Width:  |  Height:  |  Size: 281 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 5H20L14 12.5V18.5L10 20V12.5Z" stroke-width="1.5" /></svg>

After

Width:  |  Height:  |  Size: 253 B

+22
View File
@@ -98,3 +98,25 @@ sync_github_name = false
real_name = "Tomasz Lewandowski"
display_name = "Tomasz Lewandowski"
sync_github_name = false
[people.osamaamin2011]
real_name = "Osama Amin"
display_name = "Osama Amin"
sync_github_name = false
[people.giang17]
real_name = "Giang Nguyen"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.limackk]
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
+2
View File
@@ -21,3 +21,5 @@ image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
+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")];
+6 -1
View File
@@ -320,7 +320,12 @@ impl<'a> Scene<'a> {
}
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
+105
View File
@@ -515,6 +515,7 @@ impl Cms {
ProofTarget::LegacyMacRgb => srgb.map(|v| lab::linear_to_srgb(v.powf(1.8))),
ProofTarget::Protanopia => simulate_cvd(srgb, &PROTANOPIA),
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
ProofTarget::Tritanopia => TRITANOPIA.simulate(srgb),
ProofTarget::WorkingCmyk => {
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
@@ -582,6 +583,106 @@ fn simulate_cvd(srgb: [f32; 3], m: &[[f32; 3]; 3]) -> [f32; 3] {
lab::mat_mul(m, lab::srgb_linear(srgb)).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
}
// Tritanopia: Brettel, Viénot & Mollon (1997), "Computerized simulation of color appearance for
// dichromats", JOSA A 14(10), 2647-2655, https://doi.org/10.1364/JOSAA.14.002647. A color keeps its
// L and M cone responses, and its S response is replaced so that it lies on one of two half-planes in
// LMS space. Both half-planes contain the neutral axis (black to the display's white, so grays keep
// their value); one passes through the 485 nm spectral color and the other through the 660 nm one. A
// color uses the half-plane on its own side of the plane through the neutral axis and the S axis.
// The matrices are computed at compile time from these inputs:
// - linear sRGB → CIE XYZ: IEC 61966-2-1 (D65);
// - CIE XYZ → LMS: Smith & Pokorny (1975), as printed in https://daltonlens.org/understanding-cvd-simulation/;
// - the 485 nm and 660 nm colors: the Judd-Vos color matching functions,
// http://www.cvrl.org/database/data/cmfs/ciexyzjv.csv.
const LINEAR_SRGB_TO_XYZ: [[f64; 3]; 3] = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]];
const XYZ_TO_LMS: [[f64; 3]; 3] = [[0.155_14, 0.543_12, -0.032_86], [-0.155_14, 0.456_84, 0.032_86], [0.0, 0.0, 0.016_08]];
const XYZ_485_NM: [f64; 3] = [0.056_985, 0.169_87, 0.586_4];
const XYZ_660_NM: [f64; 3] = [0.161_61, 0.061, 0.000_011_906];
const TRITANOPIA: Dichromat = Dichromat::tritanope();
/// A dichromat simulation with two half-planes, as matrices on linear sRGB. `near` applies to colors
/// whose dot product with `side` is 0 or more, and `far` applies to the others.
struct Dichromat {
side: [f32; 3],
near: [[f32; 3]; 3],
far: [[f32; 3]; 3],
}
impl Dichromat {
const fn tritanope() -> Self {
let to_lms = mat3_mul(&XYZ_TO_LMS, &LINEAR_SRGB_TO_XYZ);
let to_rgb = mat3_inverse(&to_lms);
let white = mat3_apply(&to_lms, [1.0; 3]);
let a485 = mat3_apply(&XYZ_TO_LMS, XYZ_485_NM);
let a660 = mat3_apply(&XYZ_TO_LMS, XYZ_660_NM);
let mut side = cross(white, [0.0, 0.0, 1.0]);
if dot(side, a485) < 0.0 {
side = [-side[0], -side[1], -side[2]];
}
// A color on the 485 nm side lands on that half-plane.
let near = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a485)), &to_lms));
let far = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a660)), &to_lms));
// `side · lms(rgb)` is `(to_lmsᵀ · side) · rgb`.
let t = &to_lms;
let side_rgb = [
t[0][0] * side[0] + t[1][0] * side[1] + t[2][0] * side[2],
t[0][1] * side[0] + t[1][1] * side[1] + t[2][1] * side[2],
t[0][2] * side[0] + t[1][2] * side[1] + t[2][2] * side[2],
];
Dichromat { side: vec3_f32(side_rgb), near: mat3_f32(&near), far: mat3_f32(&far) }
}
fn simulate(&self, srgb: [f32; 3]) -> [f32; 3] {
let rgb = lab::srgb_linear(srgb);
let s = self.side;
let m = if s[0] * rgb[0] + s[1] * rgb[1] + s[2] * rgb[2] >= 0.0 { &self.near } else { &self.far };
lab::mat_mul(m, rgb).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
}
}
/// LMS → LMS: keeps L and M and sets S so the result lies in the plane with normal `n`.
const fn keep_lm_on_plane(n: [f64; 3]) -> [[f64; 3]; 3] {
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-n[0] / n[2], -n[1] / n[2], 0.0]]
}
const fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
const fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
const fn mat3_apply(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[dot(m[0], v), dot(m[1], v), dot(m[2], v)]
}
const fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut out = [[0.0; 3]; 3];
let mut i = 0;
while i < 3 {
out[i] = [dot(a[i], [b[0][0], b[1][0], b[2][0]]), dot(a[i], [b[0][1], b[1][1], b[2][1]]), dot(a[i], [b[0][2], b[1][2], b[2][2]])];
i += 1;
}
out
}
const fn mat3_inverse(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let c0 = cross(m[1], m[2]);
let c1 = cross(m[2], m[0]);
let c2 = cross(m[0], m[1]);
let det = dot(m[0], c0);
[[c0[0] / det, c1[0] / det, c2[0] / det], [c0[1] / det, c1[1] / det, c2[1] / det], [c0[2] / det, c1[2] / det, c2[2] / det]]
}
const fn vec3_f32(v: [f64; 3]) -> [f32; 3] {
[v[0] as f32, v[1] as f32, v[2] as f32]
}
const fn mat3_f32(m: &[[f64; 3]; 3]) -> [[f32; 3]; 3] {
[vec3_f32(m[0]), vec3_f32(m[1]), vec3_f32(m[2])]
}
/// View → Proof Setup target.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "profile", rename_all = "camelCase")]
@@ -601,6 +702,8 @@ pub enum ProofTarget {
Protanopia,
/// Colour blindness — deuteranopia type.
Deuteranopia,
/// Color blindness, tritanopia type.
Tritanopia,
}
impl ProofTarget {
@@ -615,6 +718,7 @@ impl ProofTarget {
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
"protanopia" => ProofTarget::Protanopia,
"deuteranopia" => ProofTarget::Deuteranopia,
"tritanopia" => ProofTarget::Tritanopia,
_ => return None,
})
}
@@ -627,6 +731,7 @@ impl ProofTarget {
ProofTarget::MonitorRgb => "monitorRgb".into(),
ProofTarget::Protanopia => "protanopia".into(),
ProofTarget::Deuteranopia => "deuteranopia".into(),
ProofTarget::Tritanopia => "tritanopia".into(),
}
}
/// Whether the target simulates a CMYK press (separations make sense).
+29
View File
@@ -194,6 +194,35 @@ fn colour_blindness_proofs() {
assert_eq!(ProofTarget::parse(&ProofTarget::Cmyk("X".into()).id()), Some(ProofTarget::Cmyk("X".into())));
}
/// The expected colors were computed separately with NumPy from the same published inputs (linear
/// sRGB to XYZ from IEC 61966-2-1, Smith & Pokorny's XYZ to LMS, Judd-Vos XYZ at 485 nm and 660 nm),
/// following Brettel, Viénot & Mollon (1997) with the display's white as the neutral axis.
#[test]
fn tritanopia_proof_follows_brettel_1997() {
let c = cms();
let t = ProofSetup { target: ProofTarget::Tritanopia, ..Default::default() };
assert_eq!(ProofTarget::parse("tritanopia"), Some(ProofTarget::Tritanopia));
assert_eq!(ProofTarget::Tritanopia.id(), "tritanopia");
for g in [0.0, 0.25, 0.5, 0.75, 1.0] {
let out = c.proof_srgb([g; 3], &t);
assert!(out.iter().all(|v| (v - g).abs() < 1e-4), "gray {g} stays gray: {out:?}");
}
let cases = [
// The 660 nm half-plane.
([1.0, 0.0, 0.0], [1.0, 0.0, 0.299_231]),
([1.0, 0.0, 1.0], [0.934_206, 0.388_351, 0.467_178]),
([0.9, 0.6, 0.1], [0.926_825, 0.557_667, 0.599_649]),
// The 485 nm half-plane.
([0.0, 0.0, 1.0], [0.0, 0.377_268, 0.519_933]),
([0.0, 1.0, 0.0], [0.480_503, 0.920_568, 1.0]),
([0.2, 0.4, 0.8], [0.0, 0.464_73, 0.563_516]),
];
for (rgb, want) in cases {
let got = c.proof_srgb(rgb, &t);
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rgb:?}: {got:?}, expected {want:?}");
}
}
#[test]
fn bad_icc_rejected() {
assert!(matches!(register_icc(b"not a profile", None), Err(CmsError::BadProfile(_))));
+17
View File
@@ -282,6 +282,23 @@ impl RowMap {
self.key
}
/// The tint (0..1) of the new colour current colour `c` takes, when it is a tint of the new
/// colour: what a colour linked to a global or spot swatch keeps as its tint. `None` when the
/// method makes it something else (a shade, or another hue).
pub fn tint(&self, c: Color) -> Option<f32> {
match self.method {
Method::Exact => Some(1.0),
Method::PreserveTints if is_tint(c, self.key) => Some(tint_of(c, self.key)),
Method::PreserveTints => Some(1.0),
Method::ScaleTints => Some(tint_of(c, self.key)),
Method::TintsShades => {
let d = c.to_lab().l - self.mean_l;
(d >= 0.0).then(|| 1.0 - d / (100.0 - self.mean_l).max(1e-3))
}
Method::HueShift => (c == self.key).then_some(1.0),
}
}
/// The new colour of current colour `c`, in `c`'s model.
pub fn map(&self, c: Color) -> Color {
let to = keep_model(c, self.to);
+573
View File
@@ -0,0 +1,573 @@
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
use crate::{Document, NodeId, Unit};
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, Point, Vec2};
/// What a dimension measures.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DimensionMode {
#[default]
Linear,
Angular,
Radius,
Diameter,
}
impl DimensionMode {
pub fn id(self) -> &'static str {
match self {
Self::Linear => "linear",
Self::Angular => "angular",
Self::Radius => "radius",
Self::Diameter => "diameter",
}
}
}
/// The unit a length is labelled in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LengthUnit {
#[default]
Pt,
Pc,
Px,
In,
Mm,
Cm,
M,
Ft,
Yd,
}
impl LengthUnit {
pub const ALL: [LengthUnit; 9] = [
LengthUnit::In,
LengthUnit::Mm,
LengthUnit::Pt,
LengthUnit::Pc,
LengthUnit::Px,
LengthUnit::Cm,
LengthUnit::M,
LengthUnit::Ft,
LengthUnit::Yd,
];
pub fn unit(self) -> Unit {
match self {
Self::Pt => Unit::Points,
Self::Pc => Unit::Picas,
Self::Px => Unit::Pixels,
Self::In => Unit::Inches,
Self::Mm => Unit::Millimeters,
Self::Cm => Unit::Centimeters,
Self::M => Unit::Meters,
Self::Ft => Unit::Feet,
Self::Yd => Unit::Yards,
}
}
/// The suffix written after a length.
pub fn suffix(self) -> &'static str {
match self {
Self::Pt => "pt",
Self::Pc => "pc",
Self::Px => "px",
Self::In => "in",
Self::Mm => "mm",
Self::Cm => "cm",
Self::M => "m",
Self::Ft => "ft",
Self::Yd => "yd",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LineType {
#[default]
Solid,
Dashed,
Dotted,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ArrowStyle {
#[default]
Filled,
Open,
Tick,
None,
}
/// Where the label sits across the dimension line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextPosition {
/// On the side of the dimension line away from the measured object.
#[default]
Away,
/// Between the dimension line and the measured object.
Between,
}
/// Where the label sits along the dimension line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextAlignment {
Left,
#[default]
Center,
Right,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimensionOptions {
pub units: LengthUnit,
pub precision: usize,
/// Drawing : real size. 1:10 multiplies the drawn length by ten.
pub scale: String,
pub hide_units: bool,
pub line_weight: f64,
pub line_type: LineType,
pub line_color: [u8; 3],
pub arrow_style: ArrowStyle,
pub arrow_scale: f64,
pub extension_weight: f64,
pub extension_type: LineType,
pub extension_color: [u8; 3],
pub extension_offset: f64,
pub extension_length: f64,
pub hide_extensions: bool,
pub font_family: String,
pub font_style: String,
pub font_size: f64,
pub text_color: [u8; 3],
pub text_position: TextPosition,
pub text_alignment: TextAlignment,
pub text_offset: f64,
}
impl Default for DimensionOptions {
fn default() -> Self {
// The document's own type defaults: a font every build has, black, in points.
let type_style = crate::CharStyle::default();
Self {
units: LengthUnit::Pt,
precision: 2,
scale: "1:1".into(),
hide_units: false,
line_weight: 0.5,
line_type: LineType::Solid,
line_color: [0, 0, 0],
arrow_style: ArrowStyle::Filled,
arrow_scale: 100.0,
extension_weight: 0.5,
extension_type: LineType::Solid,
extension_color: [0, 0, 0],
extension_offset: 4.0,
extension_length: 6.0,
hide_extensions: false,
font_family: type_style.font_family,
font_style: type_style.font_style,
font_size: type_style.size,
text_color: [0, 0, 0],
text_position: TextPosition::Away,
text_alignment: TextAlignment::Center,
text_offset: 4.0,
}
}
}
impl DimensionOptions {
/// Options given to a command or a tool: unknown keys are refused there, while files load
/// tolerantly so a newer version's options don't stop a document opening.
pub fn from_params(v: serde_json::Value) -> Result<Self, String> {
if let (Some(given), Ok(serde_json::Value::Object(known))) = (v.as_object(), serde_json::to_value(Self::default()))
&& let Some(k) = given.keys().find(|k| !known.contains_key(*k))
{
return Err(format!("Unknown dimension option {k}"));
}
let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
o.validate()?;
Ok(o)
}
pub fn factor(&self) -> Result<f64, String> {
let (a, b) = self.scale.split_once(':').ok_or("Scale must be drawing:real, for example 1:10")?;
let parse = |s: &str| s.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1e9);
let a = parse(a).ok_or("Invalid drawing scale")?;
let b = parse(b).ok_or("Invalid real scale")?;
let f = b / a;
if !(1e-6..=1e6).contains(&f) {
return Err("Scale ratio must be between 1:1000000 and 1000000:1".into());
}
Ok(f)
}
pub fn validate(&self) -> Result<(), String> {
self.factor()?;
if self.precision > 6 {
return Err("Choose a precision from 0 to 6".into());
}
for (v, min, max) in [
(self.line_weight, 0.1, 100.0),
(self.extension_weight, 0.1, 100.0),
(self.arrow_scale, 1.0, 1000.0),
(self.font_size, 1.0, 1000.0),
(self.extension_offset, 0.0, 10000.0),
(self.extension_length, 0.0, 10000.0),
(self.text_offset, 0.0, 10000.0),
] {
if !v.is_finite() || v < min || v > max {
return Err("Dimension size is outside its supported range".into());
}
}
if self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 {
return Err("Invalid dimension font name".into());
}
Ok(())
}
/// `value` in points for a length, in degrees for an angle.
pub fn label(&self, value: f64, angle: bool) -> Result<String, String> {
self.validate()?;
let value = if angle { value } else { value * self.factor()? / self.units.unit().points() };
if !value.is_finite() {
return Err("Non-finite dimension".into());
}
let suffix = if self.hide_units {
""
} else if angle {
"°"
} else {
self.units.suffix()
};
let sep = if suffix.is_empty() || angle { "" } else { " " };
Ok(format!("{:.*}{sep}{suffix}", self.precision, value))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SegmentRef {
pub node: NodeId,
pub subpath: usize,
pub segment: usize,
}
impl SegmentRef {
/// The segment's two end points.
pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point)> {
let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?;
if self.segment >= sp.segment_count() {
return None;
}
let seg = sp.segment(self.segment);
Some((seg.p0, seg.p3))
}
/// Whether the segment is a straight line.
pub fn is_straight(&self, doc: &Document) -> bool {
doc.node(self.node)
.and_then(|n| n.path_data())
.and_then(|p| p.subpaths.get(self.subpath))
.is_some_and(|sp| self.segment < sp.segment_count() && sp.segment_is_line(self.segment))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum DimensionSource {
Segment {
edge: SegmentRef,
},
Angle {
first: SegmentRef,
second: SegmentRef,
},
Circle {
node: NodeId,
},
/// One segment that is a perfect arc.
Arc {
edge: SegmentRef,
},
Points {
a: Option<PointRef>,
b: Option<PointRef>,
vertex: Option<PointRef>,
},
}
impl DimensionSource {
pub fn nodes(&self) -> Vec<NodeId> {
match self {
Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
Self::Angle { first, second } => vec![first.node, second.node],
Self::Circle { node } => vec![*node],
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
}
}
/// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle
/// edits keep their topology and remain linked.
pub fn topology_changed(&self, before: &Document, after: &Document) -> bool {
if matches!(self, Self::Circle { .. }) {
return false;
}
self.nodes().iter().any(|id| {
let counts = |doc: &Document| {
doc.node(*id).and_then(|n| n.path_data()).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect::<Vec<_>>())
};
let old = counts(before);
old.is_some() && old != counts(after)
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PointRef {
pub node: NodeId,
pub subpath: usize,
pub anchor: usize,
}
impl PointRef {
pub fn position(&self, doc: &Document) -> Option<Point> {
Some(doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?.anchors.get(self.anchor)?.p)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dimension {
pub mode: DimensionMode,
pub a: Point,
pub b: Point,
/// Angular vertex. For radius/diameter a is the centre and b lies on the circle.
pub vertex: Point,
pub offset: f64,
pub length: f64,
pub options: DimensionOptions,
pub source: Option<DimensionSource>,
/// How far a linked dimension was moved on its own since the last refresh: the refresh turns
/// it into a new placement unless the measured art moved too.
#[serde(skip)]
pub moved: Option<Vec2>,
}
impl Dimension {
/// A point on the drawn dimension line (linear), arc (angular) or radius line.
pub fn handle(&self) -> Point {
match self.mode {
DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset,
DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0),
DimensionMode::Radius | DimensionMode::Diameter => self.b,
}
}
/// The left normal of a → b.
pub fn normal(&self) -> Vec2 {
let u = self.b - self.a;
let len = u.hypot();
if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) }
}
fn bisector(&self) -> Vec2 {
let start = (self.a - self.vertex).atan2();
Vec2::from_angle(start + self.angle() * 0.5)
}
/// Places the dimension through `p`: the line's distance from the art (linear), the arc's
/// radius (angular) or the label's direction (radial).
pub fn place(&mut self, p: Point) {
match self.mode {
DimensionMode::Linear => self.offset = (p - self.a).dot(self.normal()),
DimensionMode::Angular => self.offset = p.distance(self.vertex).max(12.0),
DimensionMode::Radius | DimensionMode::Diameter => {
if p.distance(self.a) > 1e-6 {
let r = self.a.distance(self.b);
let u = p - self.a;
self.b = self.a + u / u.hypot() * r;
}
}
}
}
pub fn transform(&mut self, xf: Affine) {
if self.source.is_some() {
// The art it measures decides where its ends are: record the move, and the refresh
// after this edit turns it into a new placement unless the art moved with it.
let at = self.handle();
self.moved = Some((xf * at) - at + self.moved.unwrap_or_default());
return;
}
self.a = xf * self.a;
self.b = xf * self.b;
self.vertex = xf * self.vertex;
if self.mode == DimensionMode::Linear {
self.length = self.a.distance(self.b);
}
}
pub fn resolve(&mut self, doc: &Document) -> Result<(), String> {
match &self.source {
Some(DimensionSource::Points { a, b, vertex }) => {
for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] {
if let Some(r) = reference {
*point = r.position(doc).ok_or("Measured anchor no longer exists")?;
}
}
self.length = self.a.distance(self.b);
}
Some(DimensionSource::Segment { edge }) => {
let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
self.a = a;
self.b = b;
self.length = a.distance(b);
}
Some(DimensionSource::Angle { first, second }) => {
for edge in [first, second] {
if !edge.is_straight(doc) {
return Err("Angles require two straight segments".into());
}
}
let (a, b) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
let (c, d) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
let u = b - a;
let v = d - c;
let den = u.cross(v);
if den.abs() < 1e-9 {
return Err("Choose two non-parallel straight segments".into());
}
let p = a + u * ((c - a).cross(v) / den);
self.vertex = p;
self.a = if a.distance(p) > b.distance(p) { a } else { b };
self.b = if c.distance(p) > d.distance(p) { c } else { d };
}
Some(DimensionSource::Circle { node }) => {
let (center, radius) = circle(doc, *node).ok_or("Choose a circle or a perfect arc")?;
self.set_radial(center, radius);
}
Some(DimensionSource::Arc { edge }) => {
let (center, radius) = arc(doc, edge).ok_or("Choose a circle or a perfect arc")?;
self.set_radial(center, radius);
}
None => {}
}
self.validate()
}
fn set_radial(&mut self, center: Point, radius: f64) {
let direction = self.b - self.a;
let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { Vec2::new(1.0, 0.0) };
self.a = center;
self.b = center + dir * radius;
self.length = radius;
}
pub fn validate(&self) -> Result<(), String> {
self.options.validate()?;
for p in [self.a, self.b, self.vertex] {
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8 {
return Err("Dimension point is outside the canvas".into());
}
}
if !self.offset.is_finite() || self.offset.abs() > 1e6 || !self.length.is_finite() || self.length < 0.0 || self.length > 1e9 {
return Err("Invalid dimension distance".into());
}
if self.a.distance(self.b) < 1e-6 {
return Err("Choose two different points".into());
}
if self.mode == DimensionMode::Angular && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
return Err("Choose two nonzero angle arms".into());
}
Ok(())
}
pub fn angle(&self) -> f64 {
let u = self.a - self.vertex;
let v = self.b - self.vertex;
u.cross(v).atan2(u.dot(v))
}
pub fn label(&self) -> Result<String, String> {
let value = match self.mode {
DimensionMode::Angular => self.angle().abs().to_degrees(),
DimensionMode::Radius => self.a.distance(self.b),
DimensionMode::Diameter => self.a.distance(self.b) * 2.0,
DimensionMode::Linear => self.a.distance(self.b),
};
self.options.label(value, self.mode == DimensionMode::Angular)
}
}
/// Accept circular paths (including imported circles), reject ellipses and arbitrary closed shapes.
pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> {
use vectorcraft_geom::kurbo::ParamCurve;
let path = doc.node(id)?.path_data()?;
let sp = path.subpaths.first()?;
if path.subpaths.len() != 1 || !sp.closed || !(2..=64).contains(&sp.segment_count()) {
return None;
}
let bounds = path.bounds()?;
let center = bounds.center();
let radius = bounds.width() * 0.5;
if radius < 1e-6 || (bounds.height() * 0.5 - radius).abs() > radius * 0.002 {
return None;
}
for i in 0..sp.segment_count() {
for t in [0.0, 0.25, 0.5, 0.75] {
if (sp.segment(i).eval(t).distance(center) - radius).abs() > radius * 0.002 {
return None;
}
}
}
Some((center, radius))
}
/// A curved segment that follows one circle (a perfect arc): its centre and radius.
pub fn arc(doc: &Document, edge: &SegmentRef) -> Option<(Point, f64)> {
use vectorcraft_geom::kurbo::ParamCurve;
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
if edge.segment >= sp.segment_count() || sp.segment_is_line(edge.segment) {
return None;
}
let seg = sp.segment(edge.segment);
let (p, q, r) = (seg.p0, seg.eval(0.5), seg.p3);
// The circle through the ends and the middle of the segment.
let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y));
if d.abs() < 1e-9 {
return None;
}
let sq = |a: Point| a.x * a.x + a.y * a.y;
let center = Point::new(
(sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d,
(sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d,
);
let radius = center.distance(p);
if !radius.is_finite() || !(1e-6..=1e8).contains(&radius) {
return None;
}
for t in [0.125, 0.25, 0.375, 0.625, 0.75, 0.875] {
if (seg.eval(t).distance(center) - radius).abs() > radius * 0.002 {
return None;
}
}
Some((center, radius))
}
/// The two straight segments that meet at a corner anchor, if both are straight.
pub fn corner(doc: &Document, node: NodeId, subpath: usize, anchor: usize) -> Option<(SegmentRef, SegmentRef)> {
let sp = doc.node(node)?.path_data()?.subpaths.get(subpath)?;
let count = sp.segment_count();
let n = sp.anchors.len();
if count < 2 || anchor >= n {
return None;
}
// Segment i runs from anchor i to anchor i + 1 (wrapping on closed paths).
let before = if anchor > 0 {
anchor - 1
} else if sp.closed {
count - 1
} else {
return None;
};
let after = anchor;
if after >= count {
return None;
}
let first = SegmentRef { node, subpath, segment: before };
let second = SegmentRef { node, subpath, segment: after };
(first.is_straight(doc) && second.is_straight(doc)).then_some((first, second))
}
+26
View File
@@ -422,6 +422,32 @@ impl GraphSpec {
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
}
/// CSV of this graph's data (header row with series names, then category + values), as Graph
/// Data and `graph.setData` use. Always writes a header so a blank first category isn't
/// misread as one.
pub fn to_csv(&self) -> String {
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(","));
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(',');
}
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
}
out
}
}
impl Default for GraphSpec {
+160 -3
View File
@@ -145,7 +145,7 @@ fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox
let skip = |id| seen.contains(&id);
let mut chain = Vec::new();
let hit = match &c.kind {
NodeKind::Group { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
NodeKind::Group { .. } | NodeKind::Intertwine { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(),
_ => hit_leaf(c, p, opt, symbols).is_some(),
};
@@ -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> {
@@ -262,7 +336,8 @@ fn hit_children(
{
return None;
}
for (i, c) in children.iter().enumerate().rev() {
for i in hit_order(parent, children, p) {
let Some(c) = children.get(i) else { continue };
if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) {
continue;
}
@@ -270,7 +345,9 @@ fn hit_children(
let hit = match &c.kind {
// A sublayer whose Preview is off is clicked in outline, like an outline view.
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Intertwine { .. } => {
hit_children(c, p, opt, symbols, skip, chain, editable_only)
}
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
@@ -287,6 +364,23 @@ fn hit_children(
None
}
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
/// come first (from the top among them too).
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
} else {
vec![]
};
let rest = (0..children.len()).rev().filter({
let over = over.clone();
move |i| !over.contains(i)
});
over.into_iter().chain(rest)
}
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
/// outline within the tolerance. Outline mode hits the outline only.
@@ -881,6 +975,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);
+98
View File
@@ -0,0 +1,98 @@
//! Object › Intertwine: objects that pass over and under each other.
//!
//! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own
//! stacking order. A member can be drawn over the other members in part of the group: that area
//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group
//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for
//! each member with an area, a copy of it clipped to that area. Where several members are drawn
//! over the others in one place, the upper one in the stacking order ends on top.
//!
//! The members stay editable, and Release gives them back as they were (the areas let go).
//! Behaviour: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
use std::sync::Arc;
use vectorcraft_geom::{FillRule, PathData};
use crate::{Node, NodeId, NodeKind};
/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere).
pub fn over_area(n: &Node) -> Option<&PathData> {
n.extras().intertwine.as_deref().filter(|a| !a.is_empty())
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each
/// member with an area drawn over the others, as a clip group of that area around a copy of it
/// (ids 0: generated art).
pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
let mut out: Vec<Node> = children.iter().map(|c| plain(c)).collect();
for c in children {
let Some(area) = over_area(c) else { continue };
if !c.visible {
continue;
}
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
clip.appearance = crate::Appearance::default().into();
let mut copy = plain(c);
zero_ids(&mut copy);
out.push(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }));
}
out
}
/// `n` without its Intertwine area (the expansion is plain art).
fn plain(n: &Node) -> Node {
let mut n = n.clone();
if n.extras().intertwine.is_some() {
n.extras_mut().intertwine = None;
}
n
}
fn zero_ids(n: &mut Node) {
n.id = NodeId(0);
for c in n.children_mut().into_iter().flatten() {
zero_ids(Arc::make_mut(c));
}
}
#[cfg(test)]
mod tests {
use vectorcraft_geom::{Rect, shapes};
use super::*;
fn square(id: u64, x: f64) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), crate::Appearance::default()))
}
#[test]
fn members_without_areas_expand_to_themselves() {
let kids = vec![square(1, 0.0), square(2, 5.0)];
let out = expand(&kids);
assert_eq!(out.len(), 2);
assert_eq!(out.iter().map(|n| n.id).collect::<Vec<_>>(), [NodeId(1), NodeId(2)]);
}
#[test]
fn a_member_with_an_area_is_drawn_again_clipped_to_it_on_top() {
let mut a = (*square(1, 0.0)).clone();
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
let kids = vec![Arc::new(a), square(2, 5.0)];
let out = expand(&kids);
assert_eq!(out.len(), 3, "both members, then the raised copy");
assert!(out.iter().all(|n| n.extras().intertwine.is_none()), "the expansion is plain art");
let NodeKind::Group { children, clip: true } = &out[2].kind else { panic!("a clip group: {:?}", out[2].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert_eq!(children[1].path_data(), kids[0].path_data());
assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own");
}
#[test]
fn a_hidden_member_is_not_drawn_over_the_others() {
let mut a = (*square(1, 0.0)).clone();
a.visible = false;
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
assert_eq!(expand(&[Arc::new(a), square(2, 5.0)]).len(), 2);
}
}
+152 -11
View File
@@ -12,14 +12,20 @@ pub mod clipnest;
pub mod cmyk;
pub mod corners;
pub mod depth;
pub mod dimension;
pub mod graph;
pub mod hit;
mod id_index;
pub mod inks;
mod inline;
pub mod intertwine;
pub mod shared;
mod symbol_export_type;
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_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
pub mod marks;
@@ -106,9 +112,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};
@@ -119,6 +125,9 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{Swatch, SwatchGroup};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
/// Coordinates beyond this many points exceed the supported document range.
pub const MAX_COORD: f64 = 4.0e6;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
#[error("no such object {0}")]
@@ -232,7 +241,10 @@ impl Unit {
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works.
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
/// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away
/// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A
/// percentage on its own (`50%`) reads as nothing.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self).filter(|v| v.is_finite())
}
@@ -269,20 +281,41 @@ impl Unit {
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
let s = s.trim();
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
for op in ['+', '-', '*', '/'] {
// `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last
// of its operators (left to right).
for ops in [&['+', '-'][..], &['*', '/'][..]] {
// Past the first character, so a leading sign isn't an operator.
let skip = s.chars().next().map_or(0, char::len_utf8);
if let Some(i) = s[skip..].rfind(op).map(|i| i + skip) {
// A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`).
let is_sign = |i: usize| {
s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-'))
&& s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c))
};
let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i));
if let Some(i) = found.map(|(i, _)| i) {
let (l, r) = (&s[..i], &s[i + 1..]);
if l.trim().is_empty() {
continue;
}
let Some(op) = s[i..].chars().next() else { continue };
return match op {
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
'+' | '-' => {
let a = parse_measure(l, default)?;
// A percentage added or taken away is a share of everything before it:
// `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt.
let b = match percent(r) {
Some(k) => a * k,
None => parse_measure(r, default)?,
};
Some(if op == '+' { a + b } else { a - b })
}
_ => {
// A factor or divisor is a plain number; a unit after it is the expression's.
let (k, unit) = number_unit(r)?;
// A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm);
// a unit after it is the expression's.
let (k, unit) = match percent(r) {
Some(k) => (k, None),
None => number_unit(r)?,
};
let a = parse_measure(l, unit.unwrap_or(default))?;
if op == '*' {
Some(a * k)
@@ -302,7 +335,10 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
&& !s.ends_with("pt")
&& !s.ends_with("px")
{
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
// The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points).
let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 };
let picas = p.trim().parse::<f64>().ok()?.abs();
return Some(sign * (picas * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0)));
}
let (v, unit) = number_unit(s)?;
Some(unit.unwrap_or(default).to_pt(v))
@@ -329,6 +365,14 @@ fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
Some((v, unit))
}
/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`).
fn percent(s: &str) -> Option<f64> {
match number_unit(s.trim().strip_suffix('%')?)? {
(p, None) => Some(p / 100.0),
_ => None,
}
}
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
pub fn parse_number(s: &str) -> Option<f64> {
@@ -686,6 +730,10 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
/// is set, so documents without any pay nothing for them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dimensions: bool,
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
#[serde(skip)]
index: id_index::IdIndex,
@@ -706,6 +754,13 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
/// Rulers, coordinate fields and guide snapping use the same supported range.
pub fn ruler_origin_or_zero(&self) -> Point {
let origin = self.ruler_origin;
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } 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)
@@ -766,6 +821,7 @@ impl Document {
print_setup: None,
assets: vec![],
puppet: None,
dimensions: false,
index: Default::default(),
};
let id = d.alloc_id();
@@ -1399,6 +1455,13 @@ impl Document {
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.alloc_id();
// A copied annotation must never bind its source ids to unrelated art in a different
// document. Keep the measured geometry; new measurements can be linked after pasting.
if let Some(e) = n.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.source = None;
}
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
@@ -1493,6 +1556,76 @@ impl Document {
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
/// Symbol Options › Registration for `name` (default: centre).
pub fn symbol_registration(&self, name: &str) -> SymbolRegistration {
self.unknown
.get(SYMBOL_REGISTRATION)
.and_then(|m| m.get(name))
.and_then(|v| v.as_u64())
.and_then(SymbolRegistration::from_u64)
.unwrap_or_default()
}
/// Set or clear (when centre) the registration point stored for `name`.
pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) {
let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({}));
let Some(o) = m.as_object_mut() else { return };
if reg.is_center() {
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_REGISTRATION);
}
} else {
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);
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
@@ -1768,6 +1901,14 @@ mod tests {
assert_eq!(Unit::Points.format(12.5), "12.5 pt");
assert_eq!(Unit::Inches.format(144.0), "2 in");
}
#[test]
fn negative_picas_and_points_sign_applies_to_whole_value() {
assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0));
assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0));
assert_eq!(Unit::Points.parse("-2p"), Some(-24.0));
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
}
}
#[cfg(test)]
+11 -2
View File
@@ -1437,7 +1437,7 @@ fn map_node(n: &Node, w: &Warper) -> Node {
}
}
NodeKind::Mesh(m) => m.map(w.f),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
if let Some(d) = &deeper {
return map_node(&expanded_group_hooks(n, w.hooks), d);
}
@@ -1616,7 +1616,15 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
/// Is this one of the live kinds?
pub fn is_live(n: &Node) -> bool {
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_))
matches!(
n.kind,
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. }
)
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
@@ -1651,6 +1659,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
NodeKind::Repeat(r) => r.expand(),
NodeKind::PlacedDocument(p) => p.art(n),
NodeKind::Intertwine { children } => crate::intertwine::expand(children),
_ => vec![n.clone()],
}
}
+5 -1
View File
@@ -251,10 +251,14 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += ALLOC + size_of::<NodeExtras>();
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children),
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Intertwine { children } => vec_bytes(children),
NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::<BlendSpec>(),
NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::<EnvelopeKind>() + size_of::<Affine>(),
NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND,
+120 -5
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::BlendMode;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_geom::corners::cut_corners;
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
@@ -486,6 +486,12 @@ pub enum NodeKind {
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
/// [`crate::placed_document`]).
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
/// [`crate::intertwine`]).
Intertwine {
children: Vec<Arc<Node>>,
},
}
fn yes() -> bool {
@@ -543,6 +549,9 @@ pub struct NodeExtras {
/// Index of the graph series this generated group belongs to. Axes, the legend and other
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
pub series_index: Option<u32>,
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
/// the placed gradients in it with the outlines.
pub type_outlines: bool,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
@@ -555,6 +564,12 @@ pub struct NodeExtras {
pub slice: Option<Box<crate::SliceOptions>>,
/// Object › Perspective: the perspective grid plane the object is attached to.
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
/// Dimension object: the live measurement it draws; its ordinary group children hold its
/// exportable appearance (Object › Dimensions, the Dimension tool).
pub dimension: Option<Box<crate::dimension::Dimension>>,
/// 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>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -564,11 +579,14 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
wrap: None,
graph: None,
series_index: None,
type_outlines: false,
shaper: None,
graphic_style: None,
attrs: None,
slice: None,
perspective: None,
dimension: None,
intertwine: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -692,6 +710,7 @@ impl Serialize for Node {
e.wrap.is_none(),
e.graph.is_none(),
e.series_index.is_none(),
is_default(&e.type_outlines),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
@@ -699,6 +718,8 @@ impl Serialize for Node {
e.slice.is_none(),
is_default(&self.bbox_angle),
e.perspective.is_none(),
e.dimension.is_none(),
e.intertwine.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -726,6 +747,7 @@ impl Serialize for Node {
field!("wrap", &e.wrap);
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("type_outlines", &e.type_outlines);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
@@ -733,6 +755,8 @@ impl Serialize for Node {
field!("slice", &e.slice);
field!("bbox_angle", &self.bbox_angle);
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
st.end()
}
}
@@ -772,6 +796,8 @@ struct NodeWire {
#[serde(default)]
series_index: Option<u32>,
#[serde(default)]
type_outlines: bool,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
@@ -784,6 +810,10 @@ struct NodeWire {
bbox_angle: f64,
#[serde(default)]
perspective: Option<Box<crate::PerspectiveAttachment>>,
#[serde(default)]
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
}
impl From<NodeWire> for Node {
@@ -794,11 +824,14 @@ impl From<NodeWire> for Node {
wrap: w.wrap,
graph: w.graph,
series_index: w.series_index,
type_outlines: w.type_outlines,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
slice: w.slice,
perspective: w.perspective,
dimension: w.dimension,
intertwine: w.intertwine,
};
Node {
id: w.id,
@@ -843,6 +876,38 @@ impl OpacityMask {
}
}
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity
/// mask's, whose luminance over the black outside the art is then the art's alpha.
pub fn whiten(n: &mut Node) {
let white = |p: &mut Paint| match p {
Paint::Solid { color, swatch, tint } => {
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
}
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
}
}
Paint::None | Paint::Pattern { .. } => {}
};
for item in &mut n.appearance.items {
white(item.paint_mut());
}
match &mut n.kind {
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
white(&mut r.style.fill);
white(&mut r.style.stroke);
}),
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch {
whiten(Arc::make_mut(c));
}
}
}
impl Node {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
Self {
@@ -884,7 +949,8 @@ impl Node {
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
| NodeKind::Repeat(RepeatSpec { source: children, .. })
| NodeKind::Intertwine { children } => Some(children),
_ => None,
}
}
@@ -895,7 +961,8 @@ impl Node {
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
| NodeKind::Repeat(RepeatSpec { source: children, .. })
| NodeKind::Intertwine { children } => Some(children),
_ => None,
}
}
@@ -963,6 +1030,7 @@ impl Node {
NodeKind::Mesh(_) => "Mesh",
NodeKind::Repeat(r) => r.kind.label(),
NodeKind::PlacedDocument(_) => "Placed Document",
NodeKind::Intertwine { .. } => "Intertwine",
}
}
/// Name shown in the Layers panel: explicit name or `<Kind>`.
@@ -1074,6 +1142,8 @@ impl Node {
// copies out by the source's bounds measured without the document.
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
// The members drawn over the others are drawn where they are.
NodeKind::Intertwine { children } => union(children),
}
}
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
@@ -1108,6 +1178,7 @@ impl Node {
clip.geometric(symbols)
}
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
@@ -1146,6 +1217,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))
@@ -1159,6 +1237,12 @@ impl Node {
self.transform_scaled(a, &scaling.into());
}
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
// A dimension's measurement moves with it.
if let Some(e) = self.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
@@ -1197,7 +1281,10 @@ impl Node {
}
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Intertwine { children } => {
for c in children.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
@@ -1251,6 +1338,10 @@ impl Node {
{
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
}
// The area where an Intertwine member is drawn over the others moves with it.
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
area.transform(a);
}
}
/// Visit this node and all descendants depth first (paint order).
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
@@ -1379,7 +1470,7 @@ impl Node {
let frame = shapes::rectangle(p.natural()).transformed(p.xf);
out.push((frame.to_bezpath(), FillRule::NonZero));
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
}
}
@@ -1794,6 +1885,9 @@ mod kind_de {
Mesh(GradientMesh),
Repeat(RepeatSpec),
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
Intertwine {
children: Vec<Arc<Node>>,
},
}
impl<'de> Deserialize<'de> for NodeKind {
@@ -1923,6 +2017,7 @@ mod tests {
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
e.series_index = Some(2);
e.type_outlines = true;
e.graphic_style = Some(3);
e.attrs = Some(Box::default());
e.slice = Some(Box::default());
@@ -1961,6 +2056,7 @@ mod tests {
"mask",
"trace",
"series_index",
"type_outlines",
"kind",
"graphic_style",
"attrs",
@@ -2247,6 +2343,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());
+5
View File
@@ -19,6 +19,11 @@ impl<T> Shared<T> {
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
Arc::ptr_eq(&a.0, &b.0)
}
/// A weak reference to the value: it keeps the allocation, so the value's address names it
/// while the reference is kept, but not the value.
pub fn downgrade(this: &Self) -> std::sync::Weak<T> {
Arc::downgrade(&this.0)
}
}
impl<T> std::ops::Deref for Shared<T> {
+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)
}
}
+36
View File
@@ -0,0 +1,36 @@
//! Symbol Options › Registration: the 9-point grid (0 = top-left … 4 = centre … 8 = bottom-right)
//! that `symbol.place` uses as the point that lands on the click.
use vectorcraft_geom::Vec2;
/// Document unknown key: `{ symbolName: 0..8 }` for each symbol's registration point.
pub const SYMBOL_REGISTRATION: &str = "symbolRegistration";
/// Index on the 9-point grid (row-major, 4 = centre).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SymbolRegistration(pub u8);
impl Default for SymbolRegistration {
fn default() -> Self {
Self(4)
}
}
impl SymbolRegistration {
pub const CENTER: Self = Self(4);
pub fn from_u64(v: u64) -> Option<Self> {
(v <= 8).then_some(Self(v as u8))
}
pub fn is_center(self) -> bool {
self.0 == 4
}
/// Offset of this registration point from the symbol's centre, in natural-size units
/// (right +, down +).
pub fn offset_from_center(self, w: f64, h: f64) -> Vec2 {
let (col, row) = (self.0 % 3, self.0 / 3);
Vec2::new((f64::from(col) - 1.0) * (w * 0.5), (f64::from(row) - 1.0) * (h * 0.5))
}
}
+66 -3
View File
@@ -1,5 +1,7 @@
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit, names
//! and canvas readouts.
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit,
//! percentages in arithmetic, names and canvas readouts.
use proptest::prelude::*;
use super::Unit;
@@ -34,13 +36,54 @@ fn typed_units_win_over_the_fields_unit() {
assert_eq!(Unit::Pixels.parse("-4"), Some(-4.0));
}
#[test]
fn a_percentage_is_a_share_of_the_value_before_it() {
let (mm, cm) = (72.0 / 25.4, 72.0 / 2.54);
for (u, s, want) in [
(Unit::Centimeters, "3cm*50%", 1.5 * cm),
(Unit::Points, "50pt+25%", 62.5),
(Unit::Points, "50pt-25%", 37.5),
(Unit::Millimeters, "10*50%", 5.0 * mm),
(Unit::Centimeters, "3cm/50%", 6.0 * cm),
(Unit::Points, "50 pt + 25 %", 62.5),
(Unit::Points, "10+20+50%", 45.0),
// A percentage added or taken away is a share of everything before it. A percentage factor
// is a share of the term before it.
(Unit::Points, "100pt+20pt-50%", 60.0),
(Unit::Points, "100pt-20pt+50%", 120.0),
(Unit::Points, "100pt+20%-10pt", 110.0),
(Unit::Points, "10+20*50%", 20.0),
(Unit::Points, "10*2+50%", 30.0),
(Unit::Points, "10*2/50%", 40.0),
(Unit::Points, "10+-50%", 5.0),
(Unit::Points, "50pt--25%", 62.5),
(Unit::Centimeters, "3cm*-50%", -1.5 * cm),
] {
let got = u.parse(s).unwrap_or_else(|| panic!("{u:?} can't read `{s}`"));
assert!((got - want).abs() < 1e-9, "{u:?}: `{s}` → {got}, want {want}");
}
}
#[test]
fn odd_text_reads_as_nothing_without_panicking() {
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+"] {
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+", "50%", "%", "5%%", "%5", "1/0%", "10+%", "10*5pt%"] {
assert_eq!(Unit::Millimeters.parse(s), None, "{s}");
}
}
proptest! {
/// Field text made of digits, operators, `%`, spaces and unit letters reads as a finite value
/// or as nothing and does not panic.
#[test]
fn arithmetic_text_reads_as_a_finite_value_or_nothing(s in "[0-9.+*/% ptxinmcfyd-]{0,24}") {
for u in Unit::ALL {
if let Some(v) = u.parse(&s) {
prop_assert!(v.is_finite(), "{u:?}: `{s}` → {v}");
}
}
}
}
#[test]
fn units_are_named_by_label_key_or_suffix() {
for u in Unit::ALL {
@@ -66,3 +109,23 @@ fn readouts_show_two_decimals() {
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
assert_eq!(Unit::Points.number(-0.00001), "0");
}
#[test]
fn a_negative_operand_after_an_operator_is_a_sign() {
assert_eq!(Unit::Points.parse("10*-2"), Some(-20.0));
assert_eq!(Unit::Points.parse("-10/-2"), Some(5.0));
assert_eq!(Unit::Points.parse("5--3"), Some(8.0));
assert_eq!(Unit::Points.parse("5 - -3"), Some(8.0));
assert_eq!(Unit::Points.parse("5+-3"), Some(2.0));
assert_eq!(Unit::Points.parse("10-2"), Some(8.0));
assert_eq!(super::parse_number("90*-1"), Some(-90.0));
}
#[test]
fn mixed_mul_div() {
// `*` and `/` share one precedence level and run left to right.
assert_eq!(Unit::Points.parse("10/2*5"), Some(25.0));
assert_eq!(crate::parse_number("100*3/4"), Some(75.0));
assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0));
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
}
+79 -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,
@@ -734,7 +794,9 @@ impl PathAlign {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AreaOptions {
#[serde(deserialize_with = "de_rows_or_columns")]
pub rows: usize,
#[serde(deserialize_with = "de_rows_or_columns")]
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
@@ -765,6 +827,18 @@ impl Default for AreaOptions {
}
}
impl AreaOptions {
/// The most rows, and the most columns, area type can have.
pub const MAX_ROWS_COLUMNS: usize = 100;
}
/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to
/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`].
fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result<usize, D::Error> {
let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX);
Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS))
}
/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or
/// `{"shrinkText": {"minPercent": 50}}`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
+35 -12
View File
@@ -17,15 +17,21 @@ pub enum VariableKind {
Text,
/// Whether bound objects show.
Visibility,
/// A linked image's file path (the pixels swap; the object's bounds stay).
Image,
/// A graph object's data (CSV of categories and series, as Graph Data uses).
Graph,
}
impl VariableKind {
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both).
/// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both), `image` or `graph`.
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
"visibility" | "visible" => Some(Self::Visibility),
"image" => Some(Self::Image),
"graph" => Some(Self::Graph),
_ => None,
}
}
@@ -35,6 +41,8 @@ impl VariableKind {
match self {
Self::Text => "text",
Self::Visibility => "visibility",
Self::Image => "image",
Self::Graph => "graph",
}
}
}
@@ -52,13 +60,23 @@ pub struct Variable {
pub enum DataValue {
Text(String),
Visible(bool),
/// Absolute path of the image file an image variable should show.
Image(String),
/// CSV of a graph's categories and series (same shape as Graph Data / `graph.setData`).
Graph(String),
}
impl DataValue {
/// Whether this value fits the variable's kind (a dataset may carry values for
/// variables of either kind; each binding takes what fits it).
pub fn fits(&self, kind: VariableKind) -> bool {
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
matches!(
(self, kind),
(Self::Text(_), VariableKind::Text)
| (Self::Visible(_), VariableKind::Visibility)
| (Self::Image(_), VariableKind::Image)
| (Self::Graph(_), VariableKind::Graph)
)
}
}
@@ -84,9 +102,10 @@ impl DataSet {
///
/// An object may be driven by several variables — the reference app binds the selected object
/// to the selected variable, one click at a time, and never says a second binding replaces the
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
/// defined, which is what makes two text variables on one object deterministic: the last one
/// wins, and `applied` counts both.
/// first — so a binding is a set. `dataset.select` applies the text values in the order the
/// variables are defined, which is what makes two text variables on one object deterministic:
/// the last one wins, and `applied` counts both. Of two visibility variables on one object, the
/// last one defined wins too.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Variables {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -173,9 +192,10 @@ impl Document {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
}
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
/// or locked layer (the document's value is whatever it was left at).
/// 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
/// 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) {
return None;
@@ -184,14 +204,17 @@ impl Document {
match (kind, &node.kind) {
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
(VariableKind::Text, _) => None,
(VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())),
(VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())),
(VariableKind::Text | VariableKind::Image, _) => None,
}
}
/// The values a dataset captured right now: what each bound object holds, in the order the
/// variables are defined. `dataset.capture` writes these into a new row and
/// `dataset.update` into the active one; the Variables panel compares the active row with
/// them to tell whether the art still matches what it says.
/// `dataset.update` into the active one, for the variables that row holds (`all: true` adds
/// the rest); the Variables panel compares the active row with them to tell whether the art
/// still matches what it says.
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
let mut out = BTreeMap::new();
for v in &self.variables.variables {
+1
View File
@@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-plugins = { workspace = true }
image = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
+534 -10
View File
@@ -9,18 +9,26 @@
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
//! become images of their own).
//!
//! Type that Transform effects on it, or on its group or layer, only move and copy stays type in
//! each place when its characters are in solid colors and the copies do not overlap ([`moved_type`]
//! and [`container_keeping_type`] list the other conditions). The renderer still draws its outlines.
//!
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_doc::color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
use crate::distort::{transform_move, transform_offsets};
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, needs_outline, 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] {
@@ -98,8 +106,13 @@ 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.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
// 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() || has_item_geometry(n)) {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
@@ -107,9 +120,10 @@ 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));
}
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
// pieces (results, outlines, the fills' and strokes' art) get ids of their own.
if let Some(m) = evaluate_container(n) {
// 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.
if let Some(m) = container_keeping_type(n).or_else(|| evaluate_container(n)) {
return Some(bake_pieces(d, m));
}
if let Some(ch) = n.children() {
@@ -159,6 +173,201 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
}
}
/// Most copies of one type object that stay type, the original included ([`moved_type`]).
const MAX_TYPE_COPIES: usize = 10_000;
/// Most bytes that the copies of one type object that stay type take together in the document's
/// JSON form, the original included ([`moved_type`]): its text, frame or path, wrap shapes and the
/// rest of the object, once per copy.
const MAX_TYPE_COPY_BYTES: usize = 20_000_000;
/// The params of geometry effects `fx` when there are some and they are all Transform effects that
/// only move ([`transform_move`]), in order; `None` otherwise.
fn transform_moves(fx: &[&Effect]) -> Option<Vec<serde_json::Value>> {
if fx.is_empty() {
return None;
}
fx.iter().map(|e| (e.id == "distort.transform").then(|| merged_params(&e.id, &e.params)).filter(|p| transform_move(p).is_some())).collect()
}
/// Type `n` in each place that Transform effects that only move ([`transform_moves`]) copy its
/// glyph outlines to: `n` moved there, the original's place first. `None` for other objects; for
/// type that is hidden, in perspective, has effects, transparency, an opacity mask or fills and
/// strokes of its own (beyond its characters'), has characters painted with a gradient or pattern,
/// underlined or struck through, has character strokes and is scaled or sheared, holds inline
/// graphics or has no glyphs; when the effects would stop copies of its outlines early
/// ([`transform_offsets`]); when the copies would pass [`MAX_TYPE_COPIES`] or
/// [`MAX_TYPE_COPY_BYTES`]; and when the copies overlap: what one copy paints (its
/// character strokes included) reaches into another's box.
fn moved_type(n: &Node, moves: &[serde_json::Value]) -> Option<Vec<Node>> {
let NodeKind::Text(t) = &n.kind else { return None };
let flat = |p: &Paint| matches!(p, Paint::None | Paint::Solid { .. });
let [a, b, c, d, _, _] = t.xf.as_coeffs();
let rigid = (a * a + b * b - 1.0).abs() < 1e-9 && (c * c + d * d - 1.0).abs() < 1e-9 && (a * c + b * d).abs() < 1e-9;
let stroked = t.runs.iter().any(|r| r.style.has_stroke());
if !n.visible
|| (stroked && !rigid)
|| n.projection().is_some()
|| !n.has_default_transparency()
|| !n.appearance.effects.is_empty()
|| n.appearance.items.iter().any(|i| paints(i) || !item_effects(i).is_empty())
|| t.runs.iter().any(|r| r.inline.is_some() || r.style.underline || r.style.strikethrough || !flat(&r.style.fill) || !flat(&r.style.stroke))
{
return None;
}
let (_, all) = glyph_outlines(t);
let outline = PathData::from_bezpath(&all).transformed(t.xf);
let ink = outline.bounds()?;
let offsets = transform_offsets(moves, &outline)?;
if offsets.len() > MAX_TYPE_COPIES {
return None;
}
let size = serde_json::to_vec(n).ok()?.len();
if size.saturating_mul(offsets.len()) > MAX_TYPE_COPY_BYTES {
return None;
}
let strokes = t.runs.iter().map(|r| r.style.appearance().outset()).fold(0.0, f64::max);
if !apart(&offsets, ink.inflate(strokes, strokes)) {
return None;
}
Some(
offsets
.into_iter()
.map(|o| {
let mut c = n.clone();
c.transform(Affine::translate(o), false);
c
})
.collect(),
)
}
/// Do the copies of box `b` moved by each of `offsets` stay clear of each other? Copies may touch.
/// False as well when an offset is 10^15 or more times the box's width or height.
fn apart(offsets: &[Vec2], b: Rect) -> bool {
let (w, h) = (b.width(), b.height());
if offsets.len() < 2 {
return true;
}
if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) {
return false;
}
// Copies closer than `w` across and `h` down lie in the same or a neighboring cell.
let cell = |o: Vec2| {
let (x, y) = ((o.x / w).floor(), (o.y / h).floor());
(x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64))
};
let mut cells: HashMap<(i64, i64), Vec2> = HashMap::new();
for &o in offsets {
let Some((x, y)) = cell(o) else { return false };
for dx in -1..=1 {
for dy in -1..=1 {
if let Some(p) = cells.get(&(x + dx, y + dy))
&& (p.x - o.x).abs() < w
&& (p.y - o.y).abs() < h
{
return false;
}
}
}
cells.insert((x, y), o);
}
true
}
/// Type `n` whose own geometry effects only move it ([`transform_moves`]) as a group of the moved
/// type ([`moved_type`]). The group keeps `n`'s id, name, visibility, transparency, opacity mask and
/// remaining (raster) effects, and the type in it has none of them. `None` otherwise.
fn moved_type_group(n: &Node) -> Option<Node> {
if !matches!(n.kind, NodeKind::Text(_)) {
return None;
}
let moves = transform_moves(&geometry_effects(n))?;
let mut plain = n.clone();
plain.visible = true;
plain.opacity = 1.0;
plain.blend = Default::default();
plain.isolate = false;
plain.knockout_shape = false;
plain.extras_mut().mask = None;
plain.appearance.effects.clear();
let copies = moved_type(&plain, &moves)?;
let mut g = Node::group(n.id, copies.into_iter().map(Arc::new).collect());
g.name = n.name.clone();
g.visible = n.visible;
g.opacity = n.opacity;
g.blend = n.blend;
g.isolate = n.isolate;
// One element: its pieces never knock each other out.
g.knockout = Knockout::Off;
g.knockout_shape = n.knockout_shape;
g.extras_mut().mask = n.extras().mask.clone();
g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(g)
}
/// `n`, a group or layer without Pathfinder effects or fills and strokes that paint, whose geometry
/// effects only move its members ([`transform_moves`]), as plain art in which the type it holds
/// stays type: each type object moved, or replaced by a group of its copies ([`moved_type`]).
/// Type in a clipping path, other type that [`moved_type`] doesn't copy, and type inside live
/// objects or inside groups with Pathfinder or geometry effects or fills and strokes that paint, is
/// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of
/// them. `None` when no type stays type.
fn container_keeping_type(n: &Node) -> Option<Node> {
fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
if !matches!(c.kind, NodeKind::Text(_)) {
if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() {
outline_nested_text(Arc::make_mut(c), clipping);
} else if c.children().is_some() {
keep(Arc::make_mut(c), moves, clipping, kept);
}
continue;
}
// Type that isn't copied is outlined as `outline_nested_text` outlines it.
let copies = if clipping { None } else { moved_type(c, moves) };
let art = match copies {
Some(mut copies) => {
*kept = true;
match copies.pop() {
Some(only) if copies.is_empty() => Some(only),
last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())),
}
}
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
None => as_art(c, None),
};
if let Some(o) = art {
*c = Arc::new(o);
}
}
}
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || has_pathfinder(n) || has_own_paint(n) {
return None;
}
let fx = geometry_effects(n);
let moves = transform_moves(&fx)?;
let mut text = false;
n.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
if !text {
return None;
}
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut m = vectorcraft_doc::live::expand_deep(n, Some(hook));
let mut kept = false;
keep(&mut m, &moves, false, &mut kept);
if !kept {
return None;
}
reshape_leaves(&mut m, &fx);
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
m.appearance.items.clear();
m.appearance.contents_index = None;
Some(m)
}
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
/// none of `n`'s transparency.
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
@@ -359,8 +568,9 @@ pub fn bake_document(doc: &Document) -> Option<Document> {
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
fn fx(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
@@ -408,4 +618,318 @@ mod tests {
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5);
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
}
/// 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() };
Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style))))
}
/// The origins of the type objects in `d`, in paint order.
fn type_origins(d: &Document) -> Vec<(f64, f64)> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let c = t.xf.as_coeffs();
out.push((c[4], c[5]));
}
});
out
}
/// A group of the type and a 10 pt square with `effects`.
fn type_group(effects: Vec<Effect>) -> Node {
let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]);
g.appearance.effects = effects;
g
}
#[test]
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
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)),
(json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)),
] {
let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]);
let d = bake_document(&doc_with(g.clone())).unwrap();
assert_eq!(type_origins(&d), origins, "{params}");
let baked = d.node(NodeId(52)).unwrap();
assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
// The square moves with the type, as one path holding its copies.
let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap();
assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}");
let mut ids = std::collections::HashSet::new();
baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id));
// The renderer and Expand Appearance still draw it as outlines.
let mut text = 0;
evaluate_container(&g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
assert_eq!(text, 0, "{params}");
}
// Stacked effects that only move: every copy of a copy.
let d = bake_document(&doc_with(type_group(vec![
fx("distort.transform", json!({"moveH": 100, "copies": 1})),
fx("distort.transform", json!({"moveV": 100, "copies": 1})),
])))
.unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]);
}
#[test]
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut hidden = type_node(55);
hidden.visible = false;
g.children_mut().unwrap().push(Arc::new(hidden));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
assert!(!d.node(NodeId(55)).unwrap().visible);
}
#[test]
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut shadowed = type_node(55);
shadowed.opacity = 0.5;
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
// Type that clips a group is outlined as the plain outlines it clips by.
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true });
g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]);
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
let o = d.node(NodeId(55)).unwrap();
assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}");
assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
// The outlines are copied with the type.
let b = o.geometric_bounds().unwrap();
assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}");
let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap();
assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}");
}
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
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);
moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300})));
let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]);
path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))));
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true });
g.children_mut().unwrap().push(Arc::new(clip));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
}
#[test]
fn type_with_its_own_transform_effect_copies_stays_type() {
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!({}))];
let d = bake_document(&doc_with(n)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]);
// One group keeps the object's transparency and its other effects; the copies are plain.
let g = d.node(NodeId(50)).unwrap();
assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5);
assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
for c in g.children().unwrap() {
assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}");
}
// Hidden type stays hidden.
let mut n = type_node(50);
n.visible = false;
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))];
let d = bake_document(&doc_with(n)).unwrap();
assert_eq!(type_origins(&d).len(), 2);
assert!(!d.node(NodeId(50)).unwrap().visible);
}
#[test]
fn type_under_other_geometry_effects_is_still_outlined() {
for effects in [
vec![fx("distort.transform", json!({"moveH": 30, "random": true}))],
vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))],
vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))],
vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))],
vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))],
// Copies that would reach past the effect's limit, which stops them early.
vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))],
// Copies that overlap, or land on each other.
vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))],
vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))],
] {
let mut own = type_node(50);
own.appearance.effects = effects.clone();
for n in [type_group(effects.clone()), own] {
let d = bake_document(&doc_with(n)).unwrap();
assert!(type_origins(&d).is_empty(), "{effects:?}");
}
}
}
#[test]
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
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.
let mut stroked = type_node(50);
if let NodeKind::Text(t) = &mut stroked.kind {
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
t.runs[0].style.stroke_width = 20.0;
}
let mut g = type_group(moves);
g.children_mut().unwrap()[0] = Arc::new(stroked);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
#[test]
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
let mut translucent = type_node(50);
translucent.opacity = 0.5;
let mut underlined = type_node(50);
if let NodeKind::Text(t) = &mut underlined.kind {
t.runs[0].style.underline = true;
}
let mut shadowed = type_node(50);
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
let mut gradient = type_node(50);
if let NodeKind::Text(t) = &mut gradient.kind {
t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
}
let mut reshaped = type_node(50);
let mut fill = FillLayer::new(Paint::solid(Color::BLACK));
fill.effects.push(fx("path.offsetPath", json!({"offset": 2})));
reshaped.appearance.items.push(AppearanceItem::Fill(fill));
// A stroke of the type object's own (beyond its characters').
let mut stroked = type_node(50);
stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)));
// Character strokes scale with type scaled by its transform, where the outlines keep theirs.
let mut scaled = type_node(50);
if let NodeKind::Text(t) = &mut scaled.kind {
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
t.runs[0].style.stroke_width = 1.0;
t.xf *= Affine::scale(2.0);
}
let mut turned = scaled.clone();
if let NodeKind::Text(t) = &mut turned.kind {
t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5);
}
let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))];
for (t, kept) in [(turned, 2), (scaled, 0)] {
let mut g = type_group(far.clone());
g.children_mut().unwrap()[0] = Arc::new(t);
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept);
}
for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] {
let mut g = type_group(moves.clone());
g.children_mut().unwrap()[0] = Arc::new(t);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
}
#[test]
fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() {
let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))];
let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)));
let mut g = type_group(moves.clone());
g.appearance.items.push(fill.clone());
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
// A group with a fill of its own inside the moved group.
let mut inner = type_group(vec![]);
inner.id = NodeId(53);
inner.appearance.items.push(fill);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects = moves;
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
}
#[test]
fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() {
// The inner copy lands back on the original.
let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]);
inner.id = NodeId(53);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1})));
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
}
#[test]
fn type_whose_copies_hold_too_much_is_still_outlined() {
let spaced = |spaces: usize| {
let mut n = type_node(50);
if let NodeKind::Text(t) = &mut n.kind {
t.runs[0].text = format!("a{}", " ".repeat(spaces));
}
Arc::new(n)
};
// 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000
// bytes together, they are outlined.
let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))];
for (spaces, kept) in [(1000, 1001), (20_000, 0)] {
let mut g = type_group(copies.clone());
g.children_mut().unwrap()[0] = spaced(spaces);
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces");
}
// 1001 × 21 copies, past 10,000.
let mut g = type_group(vec![
fx("distort.transform", json!({"moveH": 100, "copies": 1000})),
fx("distort.transform", json!({"moveV": 100, "copies": 20})),
]);
g.children_mut().unwrap()[0] = spaced(0);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
#[test]
fn copies_are_apart_when_their_boxes_only_touch() {
let b = Rect::new(0.0, 0.0, 10.0, 5.0);
assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b));
assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b));
assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b));
assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b));
// Offsets too far for the cells' range count as overlapping.
assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0)));
}
#[test]
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
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)]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1})));
let d = bake_document(&doc_with(outer)).unwrap();
assert!(type_origins(&d).is_empty());
// The glyphs take part in Add: the result reaches past the 10 pt square.
let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.width() > 30.0, "{b:?}");
}
#[test]
fn type_is_copied_only_when_the_effect_makes_every_copy() {
// A 10 pt line with `n` anchors.
let line = |n: usize| {
let pts: Vec<Point> = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect();
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)])
};
let right = json!({"moveH": 10, "copies": 1000});
let down = json!({"moveV": 10, "copies": 1});
// 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors.
assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001));
assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none());
// A second effect copies every copy.
assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002));
assert!(transform_offsets(&[right, down], &line(1000)).is_none());
// Copies stop before one reaches 1,000,000,000 pt from the origin.
let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100));
assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none());
// An effect that also scales.
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
}
}
+75 -22
View File
@@ -103,6 +103,39 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
PathData::new(subs)
}
/// The move and the number of copies of a Transform effect with params `p` that only moves what
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
/// or moves by a random share.
pub(crate) fn transform_move(p: &Value) -> Option<(Vec2, usize)> {
let (m, copies) = transform_map(Rect::ZERO, p, 0);
let [a, b, c, d, x, y] = m.as_coeffs();
(!flag(p, "random", false) && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
}
/// The offsets at which Transform effects with params `ps`, applied in order, place `path`, the
/// original's place first, when each of them only moves it ([`transform_move`]): each effect moves
/// every place it is given, or with copies keeps them and adds each copy's. `None` when one of
/// them does more than move, or when [`transform`] stops its copies of `path` early.
pub(crate) fn transform_offsets(ps: &[Value], path: &PathData) -> Option<Vec<Vec2>> {
let mut offsets = vec![Vec2::ZERO];
let mut all = path.clone();
for p in ps {
let (v, copies) = transform_move(p)?;
let next = transform(&all, Rect::ZERO, p, 0);
if next.anchor_count() != all.anchor_count().saturating_mul(copies + 1) {
return None;
}
all = next;
if copies == 0 {
offsets.iter_mut().for_each(|o| *o += v);
continue;
}
let from = offsets.clone();
offsets.extend((1..=copies).flat_map(|k| from.iter().map(move |o| *o + v * k as f64)));
}
Some(offsets)
}
/// Largest scale (%) and move (pt) the Transform effect takes.
const MAX_SCALE: f64 = 100_000.0;
const MAX_MOVE: f64 = 1.0e7;
@@ -112,6 +145,17 @@ const MAX_REACH: f64 = 1.0e9;
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
/// Most image and symbol instance nodes a Transform effect's copies make of an art, the originals
/// included: 1000 copies of a 2000-image art. Stacked Transform effects multiply their copies
/// (three of 1000 copies make a thousand million of one image), so the limit is on the result,
/// not on each effect's count.
pub(crate) const MAX_COPY_OBJECTS: usize = 2_000_000;
/// Does `r` stay within [`MAX_REACH`] of the origin? A Transform effect's copies stop before one
/// doesn't.
pub(crate) fn within_reach(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH)
}
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
@@ -124,6 +168,36 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
/// [`transform`] whose copies make at most `max` anchors of the path, the original included.
pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData {
let (m, copies) = transform_map(b, p, seed);
if copies == 0 {
return path.transformed(m);
}
// Each copy adds the path once more, and a Transform after this one copies the lot again:
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
let each = path.anchor_count();
let mut anchors = each;
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
anchors = anchors.saturating_add(each);
if anchors > max {
break;
}
cur = cur.transformed(m);
if !cur.bounds().is_some_and(within_reach) {
break;
}
out.subpaths.extend(cur.subpaths.iter().cloned());
}
out
}
/// The Transform effect's map and how many copies of what it is applied to it makes, for bounds
/// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map
/// applies to the object itself when `copies` is 0, and once more to each of `copies` copies
/// otherwise (see [`transform_within`], and images, whose copies count nodes against
/// [`MAX_COPY_OBJECTS`]).
pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
let random = flag(p, "random", false);
// The share (0..1) of value `k` applied.
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
@@ -137,28 +211,7 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
* Affine::translate(-o);
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
if copies == 0 {
return path.transformed(m);
}
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
// Each copy adds the path once more, and a Transform after this one copies the lot again:
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
let each = path.anchor_count();
let mut anchors = each;
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
anchors = anchors.saturating_add(each);
if anchors > max {
break;
}
cur = cur.transformed(m);
if !cur.bounds().is_some_and(within) {
break;
}
out.subpaths.extend(cur.subpaths.iter().cloned());
}
out
(m, copies)
}
/// Tweak: random displacement of anchors and/or control points.
+139 -6
View File
@@ -46,7 +46,9 @@ pub fn has_pathfinder(n: &Node) -> bool {
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id))
}
/// Converts objects that aren't paths (e.g. text) to an outline path node, if possible.
/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and
/// compound paths in that art, at every depth of its groups, take part in the Pathfinder operation
/// with the object's opacity and blend mode.
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
@@ -61,8 +63,14 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
}
}
_ => {
if let Some(p) = hook.and_then(|h| h(n)) {
out.push(p);
// Every path of the outline art, at any depth, takes the object's opacity and blend
// mode.
if let Some(art) = hook.and_then(|h| h(n)) {
let first = out.len();
leaves(&art, None, out);
for l in out.iter_mut().skip(first) {
(l.opacity, l.blend) = (n.opacity, n.blend);
}
}
}
}
@@ -281,9 +289,9 @@ pub fn evaluate_container(n: &Node) -> Option<Node> {
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
Arc::new(Node::path(
@@ -319,6 +327,71 @@ mod tests {
assert_eq!(div.len(), 3);
}
/// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either
/// side of x = 100.
fn type_h() -> Node {
let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() };
Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style))))
}
/// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it.
fn square_and(front: Option<Node>, effect: &str) -> Node {
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red);
let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect());
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
g
}
/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it.
fn results(g: &Node) -> Vec<Arc<Node>> {
pathfinder_children(g, crate::text_outliner()).unwrap()
}
#[test]
fn type_changes_the_result_of_every_pathfinder_effect() {
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:?}");
// `measure` gives the number of results and their total area.
let measure = |front: Option<Node>, effect: &str| {
let r = results(&square_and(front, effect));
(r.len(), r.iter().map(|n| area(n)).sum::<f64>())
};
let unchanged: Vec<_> = PATHFINDER_EFFECTS
.iter()
.map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id)))
.filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0)
.collect();
assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}");
// The shape modes take the glyph's whole area: the part inside the square and the part outside.
let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum();
let with_type = |id: &str| measure(Some(type_h()), id).1;
assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}");
assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}");
// Outline art nested one group deeper takes part as well.
let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)]));
let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
assert!((sub.iter().map(|n| area(n)).sum::<f64>() - with_type("pathfinder.subtract")).abs() < 1e-6);
}
#[test]
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
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);
let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal);
let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply);
let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect();
assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}");
// In the shape modes a group with the type in front is one shape, styled like the type.
let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0);
let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green);
let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]);
let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect();
assert_eq!(added, [glyph]);
}
#[test]
fn a_random_transform_on_a_group_moves_its_members_together() {
let mut g = group_with("distort.transform");
@@ -330,6 +403,40 @@ mod tests {
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
}
/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`,
/// in a group with a square and Offset Path 0, which leaves every path as it is.
fn type_in_group(style: impl FnOnce(&mut Node)) -> Node {
let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts);
let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text)));
style(&mut t);
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]);
g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true });
let art = evaluate_container(&g).unwrap();
(*art.children().unwrap()[1]).clone()
}
#[test]
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
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:?}");
assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply));
// Its opacity mask, its own shadow, its knockout shape option and its visibility stay.
let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
let mask = vectorcraft_doc::OpacityMask::new(mask_art, true);
assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask));
let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true };
assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]);
assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape);
assert!(!type_in_group(|t| t.visible = false).visible);
// Its own Transform moves its outlines 30 pt before the group's effect reshapes them.
let x0 = |n: Node| n.geometric_bounds().unwrap().x0;
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true };
let moved = x0(type_in_group(|t| t.appearance.effects.push(transform)));
assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}");
}
#[test]
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
@@ -337,4 +444,30 @@ mod tests {
assert!(pathfinder_children(&g, None).is_none());
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
}
#[test]
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
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 });
// The type's characters fit the gradient to its layout; the square has it placed across itself.
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text))));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0)));
let start = |n: &Node| match n.appearance.fill_paint() {
Paint::Gradient(g) => g.geom.unwrap().start.x,
p => panic!("a gradient: {p:?}"),
};
let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]);
let mut g = Node::group(NodeId(10), vec![square, t]);
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let art = evaluate_container(&g).unwrap();
let members = art.children().unwrap();
let after = start(&members[1].children().unwrap()[0]);
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");
}
}
+65 -15
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_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,
@@ -134,7 +134,7 @@ fn lengths_of(id: &str) -> Lengths {
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
"stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]),
"stylize.dropShadow" => always(&["x", "y", "blur"]),
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
@@ -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"];
@@ -200,8 +202,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
REVOLVE,
"Revolve…",
&["Effect", "3D and Materials"],
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"expandVisibleOnly":true}),
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
),
g(
EXTRUDE,
@@ -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",
@@ -301,15 +310,15 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"stylize.scribble",
"Scribble…",
STYLIZE,
"{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}),
"{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}),
),
r(
"stylize.dropShadow",
"Drop Shadow…",
STYLIZE,
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}),
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}),
),
r(
"stylize.innerGlow",
@@ -345,8 +354,8 @@ 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)} (Normal mode)",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
"{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",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}),
),
r(
"brushStrokes.accentedEdges",
@@ -415,7 +424,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.glass",
"Glass…",
DISTORT,
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray",
json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
),
r(
@@ -432,6 +441,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…",
@@ -642,8 +658,9 @@ pub fn default_params(id: &str) -> Option<Value> {
}
}
/// `params` layered over the defaults of `id` (unknown keys are kept), with the 3D effects'
/// earlier `perspective` read as `perspectiveAngle` (see [`upgrade_params`]).
/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see
/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and
/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`.
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
Some(Value::Object(m)) => m.clone(),
@@ -660,14 +677,17 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
/// (`lightRotation`, `lightHeight`, `lightBehind`). Earlier values (from a file or a script of
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
let mut m = m.clone();
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
upgrade_scribble(id, &mut m);
Value::Object(m)
}
other => other.clone(),
@@ -700,6 +720,21 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
}
}
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
if id != "stylize.scribble" {
return;
}
if let Some(variation) = m.remove("variation") {
for key in ["overlapVariation", "spacingVariation"] {
m.entry(key).or_insert_with(|| variation.clone());
}
}
if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) {
let wrapped = angle.rem_euclid(360.0);
m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped }));
}
}
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
@@ -749,6 +784,18 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
effects.iter().any(|e| e.visible && is_geometry(&e.id))
}
/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws
/// what the plug-in installed under its id draws ([`plugin_revision`]).
pub fn has_plugin_effect(n: &Node) -> bool {
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
}
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GeomContext<'a> {
@@ -834,6 +881,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 {
+79 -5
View File
@@ -1,8 +1,8 @@
//! Blur › Radial Blur and Smart Blur.
//! Blur › Radial Blur and Smart Blur (Normal, Edge Only and Overlay Edge).
use vectorcraft_geom::{Affine, Point, Vec2};
use super::{PixelSpace, Px16, box_blur, to8, to16};
use super::{PixelSpace, Px16, box_blur, pick, to8, to16};
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
pub(super) fn zoom_extent(amount: f64) -> f64 {
@@ -69,6 +69,39 @@ pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
const MAX_SMART: f64 = 4096.0;
/// Smart Blur's modes, in its Mode menu's order: (label, parameter value).
pub const SMART_BLUR_MODES: [(&str, &str); 3] = [("Normal", "normal"), ("Edge Only", "edgeOnly"), ("Overlay Edge", "overlayEdge")];
/// What Smart Blur draws ([`SMART_BLUR_MODES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SmartBlurMode {
/// The object blurred.
Normal,
/// The edges in white on black.
EdgeOnly,
/// The edges in white over the object blurred.
OverlayEdge,
}
impl SmartBlurMode {
const ALL: [SmartBlurMode; 3] = [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge];
/// The mode of `value` (a [`SMART_BLUR_MODES`] value, any case); Normal otherwise.
pub fn parse(value: &str) -> Self {
pick(&SMART_BLUR_MODES, &Self::ALL, value, SmartBlurMode::Normal)
}
}
/// Smart Blur's `threshold` (8-bit levels) in the levels of [`Px16`].
fn levels(threshold: f64) -> i32 {
(threshold.clamp(0.0, 255.0) * 257.0) as i32
}
/// Do all of `a`'s channels lie within `t` levels of `b`'s?
fn close(a: &Px16, b: &Px16, t: i32) -> bool {
a.iter().zip(b).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t)
}
/// 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
@@ -102,7 +135,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
} else {
&src
};
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
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() {
let mut acc = c.map(u64::from);
@@ -115,8 +148,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
let i = sy as usize * w + sx as usize;
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
// The neighbour itself and its surroundings both close enough: edges stay sharp.
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
if close(own) && close(s) {
if close(own, c, t) && close(s, c, t) {
for (a, v) in acc.iter_mut().zip(s) {
*a += *v as u64;
}
@@ -127,3 +159,45 @@ 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.
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];
}
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);
}
for ((p, c), on_edge) in px.iter_mut().zip(&src).zip(&edge) {
let a = to8(c[3]);
if *on_edge {
*p = [a; 4];
} else if !overlay {
*p = [0, 0, 0, a];
}
}
}
+22 -3
View File
@@ -7,8 +7,11 @@
//! white grain. Every surface lies in document space around the object's centre, as the Texture
//! filters' do.
use std::sync::Arc;
use vectorcraft_geom::{Point, Vec2};
use super::loaded::{LOADED, LoadedTexture, texture};
use super::pixelate::{Place, value_noise};
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, pick};
@@ -20,13 +23,19 @@ const SALT_RIPPLE: u64 = 136;
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
/// Glass's Texture menu once a texture is loaded: [`GLASS_TEXTURES`], then the loaded one.
pub const GLASS_TEXTURES_LOADED: [(&str, &str); 5] =
[("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens"), ("Loaded Texture", LOADED)];
/// A Glass surface.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GlassTexture {
Blocks,
Canvas,
Frosted,
TinyLens,
/// An image the user loaded ([`LoadedTexture`]).
Loaded(Arc<LoadedTexture>),
}
impl GlassTexture {
@@ -37,13 +46,23 @@ impl GlassTexture {
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
}
/// The surface of `value` with `stored` the effect's `textureImage`: the loaded texture
/// for [`LOADED`] (any case) when `stored` holds one, else as [`GlassTexture::parse`].
pub fn parse_with(value: &str, stored: Option<&str>) -> Self {
match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) {
Some(t) => GlassTexture::Loaded(t),
None => Self::parse(value),
}
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
fn height(self, p: Point) -> f64 {
fn height(&self, p: Point) -> f64 {
match self {
GlassTexture::Blocks => blocks(p),
GlassTexture::Canvas => Texture::Canvas.height(p),
GlassTexture::Frosted => frosted(p),
GlassTexture::TinyLens => tiny_lens(p),
GlassTexture::Loaded(t) => t.height(p),
}
}
}
@@ -103,7 +122,7 @@ pub(super) fn glass(
space: &PixelSpace,
distortion: f64,
smoothness: f64,
texture: GlassTexture,
texture: &GlassTexture,
scaling: f64,
invert: bool,
) {
+168
View File
@@ -0,0 +1,168 @@
//! Load Texture (Effect › Distort › Glass): a surface made from an image the user loads.
//!
//! The effect keeps the image's gray levels in its parameters ([`TEXTURE_IMAGE`]: a grayscale PNG,
//! base64), so the document saves the texture with the effect, and copies of the effect carry it.
//! Each pixel's gray is the surface's height there: white stands [`RELIEF`] above black. The image
//! lies one pixel per point at 100 %, centered on the object's center and repeated in both
//! directions.
use std::io::Cursor;
use std::sync::{Arc, Mutex, PoisonError};
use vectorcraft_geom::Point;
use super::texture::luma;
/// The effect parameter that holds a loaded texture: its gray levels as a grayscale PNG, base64.
pub const TEXTURE_IMAGE: &str = "textureImage";
/// The Texture menu's value for the loaded texture.
pub const LOADED: &str = "loaded";
/// How far (points) a loaded texture's white stands above its black: a black pixel beside a white
/// one gives a slope of about 1, the most the built-in surfaces have.
const RELIEF: f64 = 1.0;
/// How many decoded textures [`texture`] keeps.
const KEEP: usize = 4;
/// A loaded texture: `width` × `height` gray levels, row by row from the top.
#[derive(Debug, PartialEq, Eq)]
pub struct LoadedTexture {
width: u32,
height: u32,
gray: Vec<u8>,
}
impl LoadedTexture {
/// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's
/// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray).
/// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels.
pub fn from_rgba(width: u32, height: u32, rgba: &[u8]) -> Option<Self> {
let n = (width as usize).checked_mul(height as usize).filter(|n| *n > 0)?;
let (px, rest) = rgba.as_chunks::<4>();
if px.len() != n || !rest.is_empty() {
return None;
}
let gray = px
.iter()
.map(|p| {
let a = f32::from(p[3]) / 255.0;
let l = luma([p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0));
((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8
})
.collect();
Some(Self { width, height, gray })
}
/// The size in pixels.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Its [`TEXTURE_IMAGE`] value: the gray levels as a grayscale PNG, base64; `None` when the
/// PNG can't be written.
pub fn to_stored(&self) -> Option<String> {
let img = image::GrayImage::from_raw(self.width, self.height, self.gray.clone())?;
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(base64_encode(&png))
}
/// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`];
/// `None` when `png` isn't a PNG or holds no pixels.
fn from_png(png: &[u8]) -> Option<Self> {
let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
reader.limits(limits);
let img = reader.decode().ok()?.to_luma8();
let (width, height) = img.dimensions();
(width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() })
}
/// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray
/// levels around it interpolated, 0 for black and [`RELIEF`] for white.
pub(super) fn height(&self, p: Point) -> f64 {
let (w, h) = (f64::from(self.width), f64::from(self.height));
// Pixel centers lie at whole coordinates, and the image's center lies at the origin.
let (x, y) = (p.x + 0.5 * w - 0.5, p.y + 0.5 * h - 0.5);
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let at = |i: f64, j: f64| {
let (i, j) = (i.rem_euclid(w) as usize, j.rem_euclid(h) as usize);
self.gray.get(j * self.width as usize + i).map_or(0.5, |g| f64::from(*g) / 255.0)
};
let top = at(x0, y0) + (at(x0 + 1.0, y0) - at(x0, y0)) * fx;
let bottom = at(x0, y0 + 1.0) + (at(x0 + 1.0, y0 + 1.0) - at(x0, y0 + 1.0)) * fx;
RELIEF * (top + (bottom - top) * fy)
}
}
/// The textures [`texture`] decoded last, with their stored form, the newest last.
static DECODED: Mutex<Vec<(String, Arc<LoadedTexture>)>> = Mutex::new(Vec::new());
/// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last
/// [`KEEP`] textures decoded are kept for the next call.
pub fn texture(stored: &str) -> Option<Arc<LoadedTexture>> {
let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(i) = decoded.iter().position(|(s, _)| s == stored) {
let entry = decoded.remove(i);
let t = entry.1.clone();
decoded.push(entry);
return Some(t);
}
let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?);
if decoded.len() >= KEEP {
decoded.remove(0);
}
decoded.push((stored.to_string(), t.clone()));
Some(t)
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 (RFC 4648, with padding), written as `vectorcraft_format::base64_encode`
/// writes it.
fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
/// Standard base64 (RFC 4648, with padding; whitespace skipped), read as
/// `vectorcraft_format::base64_decode` reads it; `None` when it isn't base64.
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if !clean.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
for c in clean.chunks(4) {
let mut n = 0u32;
let mut pad = 0;
for &b in c {
n <<= 6;
if b == b'=' {
pad += 1;
} else {
n |= val(b)?;
}
}
out.push((n >> 16) as u8);
if pad < 2 {
out.push((n >> 8) as u8);
}
if pad < 1 {
out.push(n as u8);
}
}
Some(out)
}
+40 -15
View File
@@ -1,7 +1,8 @@
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
//! 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.
//!
@@ -17,8 +18,10 @@ mod blur;
mod brushstrokes;
mod distort;
mod edges;
mod loaded;
mod pixelate;
mod sharpen;
mod sketch;
mod texture;
mod video;
@@ -27,13 +30,16 @@ use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{flag, num, text};
pub use blur::{SMART_BLUR_MODES, SmartBlurMode};
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
pub use distort::{GLASS_TEXTURES, GlassTexture};
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; 29] = [
"artistic.filmGrain",
"blur.radial",
"blur.smart",
@@ -53,6 +59,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
"pixelate.mezzotint",
"pixelate.pointillize",
"sharpen.unsharpMask",
"sketch.halftonePattern",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
@@ -66,7 +73,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
/// are in document units.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
@@ -77,7 +84,10 @@ pub enum PixelFx {
/// 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.
SmartBlur { radius: f64, threshold: f64, samples: u32 },
/// 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.
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.
AccentedEdges { width: f64, brightness: f64, smoothness: f64 },
@@ -107,9 +117,9 @@ pub enum PixelFx {
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
/// `invert`.
/// Distort › Glass: the object seen through a `texture` surface (a built-in one, or an image
/// the user loaded) scaled by `scaling` (0.5..2), bent by `distortion` (0..20), smoothed by
/// `smoothness` (1..15), its heights turned over when `invert`.
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
/// `magnitude` (0..20) says.
@@ -117,6 +127,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
@@ -211,6 +224,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
mode: SmartBlurMode::parse(text(p, "blurMode", "normal")),
},
"brushStrokes.accentedEdges" => PixelFx::AccentedEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
@@ -258,7 +272,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
"distort.glass" => PixelFx::Glass {
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
texture: GlassTexture::parse(text(p, "texture", "frosted")),
texture: GlassTexture::parse_with(text(p, "texture", "frosted"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
invert: flag(p, "invert", false),
},
@@ -270,6 +284,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),
@@ -346,7 +365,7 @@ 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::ColorHalftone { .. }
@@ -389,7 +408,7 @@ impl PixelFx {
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
@@ -412,7 +431,12 @@ impl PixelFx {
match *self {
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
blur::smart(px, w, h, to_px(radius), threshold, samples)
}
PixelFx::SmartBlur { radius, threshold, samples, mode } => {
blur::smart_edges(px, w, h, to_px(radius), threshold, samples, mode == SmartBlurMode::OverlayEdge)
}
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness),
PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength),
@@ -422,11 +446,12 @@ impl PixelFx {
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
PixelFx::Glass { distortion, smoothness, ref texture, scaling, invert } => {
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
}
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),
@@ -448,8 +473,8 @@ impl PixelFx {
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
/// otherwise.
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
fn pick<T: Clone>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| t.clone())
}
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
+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);
});
}
+292 -4
View File
@@ -36,7 +36,7 @@ fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
let e = Effect { id: id.into(), params: p, visible: true };
match raster_effects(&[e]).first() {
Some(RasterFx::Pixel(p)) => *p,
Some(RasterFx::Pixel(p)) => p.clone(),
other => panic!("{id}: {other:?}"),
}
}
@@ -465,11 +465,22 @@ fn params_take_defaults_and_stay_in_range() {
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
);
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::Normal });
assert_eq!(
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low", "blurMode": "EDGEONLY"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5, mode: SmartBlurMode::EdgeOnly }
);
assert_eq!(
fx("blur.smart", json!({"blurMode": "overlayEdge"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::OverlayEdge }
);
assert_eq!(fx("blur.smart", json!({"blurMode": "overlay"})), fx("blur.smart", json!({})));
// Every Mode menu value is documented in the catalogue and parses to a mode of its own.
let doc = crate::effect_info("blur.smart").unwrap().params;
for (i, (_, v)) in SMART_BLUR_MODES.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
assert_eq!(SmartBlurMode::parse(v), [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge][i], "{v}");
}
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
assert_eq!(
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
@@ -616,6 +627,105 @@ fn smart_blur_smooths_noise_and_keeps_edges() {
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
}
/// FNV-1a of `bytes`.
fn digest(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
}
#[test]
fn smart_blur_normal_mode_keeps_its_pixels() {
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]
});
let mut sums = Vec::new();
for p in [json!({}), json!({"radius": 2, "threshold": 10, "quality": "high"}), json!({"radius": 12, "threshold": 40, "quality": "low"})] {
let mut d = src.clone();
fx("blur.smart", p.clone()).apply(&mut d, w, h, &space(w, h));
let mut normal = src.clone();
let mut p = p;
p["blurMode"] = json!("normal");
fx("blur.smart", p).apply(&mut normal, w, h, &space(w, h));
assert_eq!(d, normal);
sums.push(digest(&d));
}
// The pixels Smart Blur gave before it had a mode.
assert_eq!(sums, [11495339692959753241, 14281410954765351056, 5111933201350526354]);
}
/// 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.
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 {
0..3 => grey(if (x + y) % 2 == 0 { 56 } else { 64 }),
3..6 => grey(if (x + y) % 2 == 0 { 66 } else { 74 }),
_ => grey(200),
});
(w, h, src, move |x, y| x == 0 || y == 0 || x == w - 1 || y == h - 1 || x == 6)
}
#[test]
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));
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);
// 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));
for y in 1..h - 1 {
for x in 1..6 {
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
assert_eq!(at(&low, w, x, y)[0] == 255, lighter, "({x}, {y})");
}
}
// White and black keep each pixel's coverage, and transparency stays.
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));
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)) {
(true, _) => [128; 4],
(_, true) => [0, 0, 0, 128],
_ => [0; 4],
});
assert_eq!(s, want);
}
#[test]
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));
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));
for y in 0..h {
for x in 0..w {
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
assert_eq!(at(&d, w, x, y), want, "({x}, {y})");
}
}
// Parameters past their ranges, at any resolution, still finish.
for mode in ["edgeOnly", "overlayEdge"] {
for s in [PixelSpace { px: 1e-9, ..space(w, h) }, PixelSpace { px: 1e9, ..space(w, h) }] {
let mut e = src.clone();
fx("blur.smart", json!({"blurMode": mode, "radius": 1e308, "threshold": 1e308, "quality": "high"})).apply(&mut e, w, h, &s);
}
}
}
#[test]
fn unsharp_mask_overshoots_at_edges() {
let (w, h) = (40, 10);
@@ -1309,6 +1419,68 @@ fn glass_bends_the_image_by_its_texture() {
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
}
/// The stored form of a `w` × `h` texture whose straight RGBA pixels `f` gives.
fn stored(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> String {
LoadedTexture::from_rgba(w as u32, h as u32, &image(w, h, f)).and_then(|t| t.to_stored()).unwrap()
}
#[test]
fn a_loaded_texture_is_its_gray_over_middle_gray() {
let t = stored(3, 1, |x, _| [[255, 255, 255, 255], [0, 0, 0, 0], [255, 0, 0, 255]][x]);
let loaded = texture(&t).unwrap();
assert_eq!(loaded.size(), (3, 1));
assert_eq!(texture(&t), Some(loaded.clone()), "read again from the cache");
// A white pixel is 1 high, a transparent one is middle gray and a red one is its luma. Heights
// between pixel centers are interpolated, and the image repeats: x = -1 is the first pixel's
// center, and x = 2 is the same pixel again.
let h = |x: f64| loaded.height(Point::new(x, 0.0));
assert_eq!(h(-1.0), 1.0);
assert_eq!(h(0.0), 128.0 / 255.0);
assert_eq!(h(1.0), 76.0 / 255.0);
assert_eq!(h(2.0), 1.0);
assert_eq!(h(-0.5), 0.5 * (1.0 + 128.0 / 255.0));
assert!(LoadedTexture::from_rgba(0, 4, &[]).is_none() && LoadedTexture::from_rgba(2, 2, &[0; 12]).is_none());
assert!(texture("not base64!").is_none() && texture("aGVsbG8=").is_none(), "base64 of a PNG only");
}
#[test]
fn glass_bends_the_image_by_a_loaded_texture() {
let (w, h) = (48, 40);
let src = stripes(w, h);
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
d
};
// Rings around the texture's center, 9 × 7 pixels, repeated.
let rings = stored(9, 7, |x, y| grey((((x as f64 - 4.0).hypot(y as f64 - 3.0) * 60.0) as u32 % 256) as u8));
let loaded = json!({"texture": "loaded", "textureImage": rings, "distortion": 12});
let bent = run(loaded.clone());
assert_ne!(bent, src);
for (_, texture) in GLASS_TEXTURES {
assert_ne!(bent, run(json!({"texture": texture, "distortion": 12})), "differs from {texture}");
}
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "invert": true})), bent, "inverted");
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "scaling": 200})), bent, "scaled");
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
let p = at(&bent, w, x, y);
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "at ({x}, {y}): {p:?}");
}
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 0})), src, "no distortion, no change");
// A flat texture bends nothing, whatever its gray; so does a transparent one (middle gray).
for flat in [grey(0), grey(200), [0, 0, 0, 0]] {
assert_eq!(run(json!({"texture": "loaded", "textureImage": stored(5, 5, |_, _| flat), "distortion": 20})), src, "{flat:?}");
}
// The image is kept but not used with a built-in texture; without one, "loaded" is Frosted.
assert_eq!(run(json!({"texture": "blocks", "textureImage": rings, "distortion": 12})), run(json!({"texture": "blocks", "distortion": 12})));
assert_eq!(fx("distort.glass", json!({"texture": "loaded"})), fx("distort.glass", json!({})));
assert_eq!(fx("distort.glass", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("distort.glass", json!({})));
assert_eq!(
fx("distort.glass", loaded),
PixelFx::Glass { distortion: 12.0, smoothness: 3.0, texture: GlassTexture::Loaded(texture(&rings).unwrap()), scaling: 1.0, invert: false }
);
}
#[test]
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
let (w, h) = (64, 32);
@@ -1721,3 +1893,119 @@ 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}");
}
}
}
+6
View File
@@ -21,6 +21,9 @@ pub enum RasterFx {
dy: f64,
blur: f64,
color: Color,
/// Darkness (0..1): the shadow is the object's own colors with this share of black added
/// instead of `color`; `None` when it is `color`.
darkness: Option<f32>,
},
OuterGlow {
mode: BlendMode,
@@ -111,6 +114,9 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
blur,
color: color_param(&p, Color::BLACK),
darkness: text(&p, "shade", "color")
.eq_ignore_ascii_case("darkness")
.then(|| (num(&p, "darkness", 100.0) / 100.0).clamp(0.0, 1.0) as f32),
},
"stylize.outerGlow" => RasterFx::OuterGlow {
mode: blend_mode(text(&p, "mode", "screen")),
+237 -23
View File
@@ -5,12 +5,14 @@
//! (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;
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params};
@@ -30,9 +32,25 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
/// 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) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let (runs, all, _) = laid_out(t);
(runs, all)
}
/// [`glyph_outlines`] and the bounds of `t`'s layout in text space.
fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath, Rect) {
let db = vectorcraft_text::FontDb::global();
let layout = vectorcraft_text::layout(db, t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
for g in &layout.glyphs {
@@ -41,25 +59,49 @@ pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::Bez
}
all.extend(g.outline.iter());
}
(runs, all)
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
if let Some(r) = runs.get_mut(run) {
r.extend(bar.iter());
}
all.extend(bar.iter());
}
(runs, all, layout.bounds)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// and (when the object has its own fills/strokes) the whole outline with the object's items below
/// the characters under them and the others over them, as the renderer paints them.
/// the characters under them and the others over them, as the renderer paints them. Each run's
/// path is placed by the type's transform, which also scales its stroke weights and dashes and
/// transforms its pattern tiles; its unplaced gradients are first fixed to the layout's bounds in
/// text space. The object's own unplaced gradients are fixed to the bounding box of those bounds
/// in the document. The group is marked as type's outlines
/// ([`vectorcraft_doc::NodeExtras::type_outlines`]).
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let (runs, all) = glyph_outlines(t);
let (runs, all, bounds) = laid_out(t);
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() };
let mut children: Vec<Arc<Node>> = runs
.iter()
.zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance())))
.map(|(bp, run)| {
let mut ap = run.style.appearance();
ap.pin_gradients(bounds);
let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap);
piece.transform(t.xf, scaling);
Arc::new(piece)
})
.collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let (below, above) = n.appearance.split_contents();
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
let tb = t.xf.transform_rect_bbox(bounds);
let glyphs = |items: &[AppearanceItem]| {
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
ap.pin_gradients(tb);
Arc::new(Node::path(n.id, path(&all), ap))
};
if !below.is_empty() {
children.insert(0, glyphs(below));
}
@@ -67,7 +109,9 @@ pub fn outline_text(n: &Node) -> Option<Node> {
children.push(glyphs(above));
}
}
Some(Node::group(n.id, children))
let mut g = Node::group(n.id, children);
g.extras_mut().type_outlines = true;
Some(g)
}
/// `n` as plain art that geometry effects can reshape, without its transparency and object
@@ -86,7 +130,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
_ => return None,
};
outline_nested_text(&mut art);
outline_nested_text(&mut art, false);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
@@ -97,24 +141,37 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Some(art)
}
/// Replace type anywhere inside `n` by its outlines.
fn outline_nested_text(n: &mut Node) {
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by.
pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if let Some(o) = outline_text(c) {
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) => as_art(c, None),
_ => None,
};
if let Some(o) = art {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_nested_text(Arc::make_mut(c));
outline_nested_text(Arc::make_mut(c), clipping);
}
}
}
/// `n` (see [`needs_outline`]) with its object-level geometry effects 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, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
/// `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. 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) {
return None;
}
as_art(n, symbol_art)
@@ -154,11 +211,13 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
out.visible = n.visible;
out.opacity = n.opacity;
out.blend = n.blend;
out.isolate = n.isolate;
// One element: its pieces never knock each other out.
out.knockout = vectorcraft_doc::Knockout::Off;
out.knockout_shape = n.knockout_shape;
out.extras_mut().mask = n.extras().mask.clone();
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(out)
@@ -197,9 +256,15 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
}
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box and the art's seed (a random Transform moves the pieces together).
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
let seed = art.id.0;
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
// pieces, from the outlines' bounds before the effects to their bounds after them.
let mut outlines = vec![];
type_outline_bounds(art, &mut outlines);
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
@@ -208,7 +273,156 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
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);
transform_placed(art, m, copies);
}
}
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
rebase_type_outlines(art, &mut outlines.into_iter());
}
}
/// 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>>) {
if n.extras().type_outlines {
out.push(n.geometric_bounds());
} else if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind {
for c in children {
type_outline_bounds(c, out);
}
}
}
/// Move and scale the placed gradients of each group of type's outlines under `n` from its bounds
/// in `before` ([`type_outline_bounds`] before its paths changed) to its bounds now.
fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
if !n.extras().type_outlines {
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
for c in children.iter_mut() {
rebase_type_outlines(Arc::make_mut(c), before);
}
}
} else if let Some(Some(from)) = before.next()
&& let Some(to) = n.geometric_bounds()
&& from != to
{
for_each_leaf(n, &mut |leaf| {
if leaf.appearance.has_placed_gradient() {
follow_boxes(&mut leaf.appearance, from, to);
}
});
}
}
/// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box
/// onto the other (strokes: their stroke-inflated boxes). The start and end go where
/// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are
/// mapped with them.
fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) {
for i in &mut ap.items {
match i {
AppearanceItem::Fill(f) => {
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut f.paint {
g.transform(box_map(from, to));
}
}
AppearanceItem::Stroke(s) => {
let m = box_map(s.paint_bounds(from), s.paint_bounds(to));
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut s.paint {
g.transform(m);
}
}
}
}
}
/// The map that takes box `from` onto box `to` along each axis (along an axis `from` has no extent
/// on, a move from its center to `to`'s).
fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
let axis = |a0: f64, a1: f64, b0: f64, b1: f64| {
if (a1 - a0).abs() > 1e-12 {
let s = (b1 - b0) / (a1 - a0);
(s, b0 - a0 * s)
} else {
(1.0, (b0 + b1 - a0 - a1) / 2.0)
}
};
let (sx, tx) = axis(from.x0, from.x1, to.x0, to.x1);
let (sy, ty) = axis(from.y0, from.y1, to.y0, to.y1);
vectorcraft_geom::Affine::new([sx, 0.0, 0.0, sy, tx, ty])
}
/// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as
/// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more
/// follow, each mapped once more than the last. Without copies the object itself takes the map,
/// as a path does.
fn transform_placed(n: &mut Node, m: Affine, copies: usize) {
transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS)
}
/// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art,
/// the originals included: each copy adds the object once more, and a Transform after this one
/// copies the lot again, so copies stop where the result would pass `max` (#2042).
pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) {
let mut room = max.saturating_sub(placed_count(n));
copy_placed(n, m, copies, &mut room);
}
/// An object that a Transform effect places through its transform rather than reshapes: an image
/// (pixel space to document) or a symbol instance (the symbol's art to document).
fn is_placed(n: &Node) -> bool {
matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. })
}
/// The walk of [`transform_placed`]: the map and its copies go to each image and symbol instance in
/// document order, each copy taking one from `room` (what the art can still grow by), until the
/// budget is full. As with a path, the copies stop before one reaches past
/// [`crate::distort::within_reach`].
fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
if !is_placed(c) {
copy_placed(Arc::make_mut(c), m, copies, room);
continue;
}
let src = (**c).clone();
let placed = |xf: Affine| {
let mut one = src.clone();
match &mut one.kind {
NodeKind::Image(im) => im.xf = xf * im.xf,
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
_ => {}
}
one
};
if copies == 0 {
*c = Arc::new(placed(m));
continue;
}
// The copies become nodes of their own, under a group with the object's id, name and place
// in the art (as the runs an outlined text gets).
let mut placements: Vec<Arc<Node>> = vec![Arc::new(placed(Affine::IDENTITY))];
let mut xf = Affine::IDENTITY;
for _ in 0..copies.min(*room) {
xf *= m;
let copy = placed(xf);
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
}
*room -= placements.len() - 1;
let mut g = Node::group(src.id, placements);
g.name = src.name.clone();
*c = Arc::new(g);
}
}
/// How many images and symbol instances `n` holds below itself.
fn placed_count(n: &Node) -> usize {
n.children().into_iter().flatten().map(|c| usize::from(is_placed(c)) + placed_count(c)).sum()
}
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
@@ -216,7 +430,7 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
pub(crate) fn outlined_members(n: &Node) -> Node {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
outline_nested_text(&mut art);
outline_nested_text(&mut art, false);
art
}
+108 -5
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{PathData, Rect};
use vectorcraft_three_d::{Edge, Revolve};
@@ -38,6 +38,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
smooth_shading: true,
cap_ends: flag(p, "cap", true),
}
}
@@ -52,6 +54,9 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
return Err("Revolve edge must be left or right".into());
}
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
return Err("Revolve cap must be a boolean".into());
}
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
}
@@ -95,7 +100,10 @@ fn surface(
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
.flatten();
let faces = visible.unwrap_or_else(|| {
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
.collect()
});
let children = faces
.into_iter()
@@ -104,13 +112,17 @@ fn surface(
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let s = face.brightness as f32;
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
items.push(AppearanceItem::Fill(fill));
} else {
items.push(AppearanceItem::Fill(fill));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
shadow.opacity = (1.0 - face.brightness) as f32;
if let Some(g) = face.gradient {
shadow.paint = shading_paint(g, |_| Color::BLACK, |s| (1.0 - s) as f32);
} else {
shadow.opacity = (1.0 - face.brightness) as f32;
}
items.push(AppearanceItem::Fill(shadow));
}
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
@@ -121,6 +133,9 @@ fn surface(
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
// Acute face corners must stay within the overlap width. Miter joins
// amplify this seam stroke into visible spikes along the silhouette.
edge.join = LineJoin::Round;
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
@@ -134,6 +149,15 @@ fn surface(
Some(group)
}
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops =
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
match item {
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
@@ -224,6 +248,85 @@ mod tests {
crate::new_effect(REVOLVE, &json!({})).unwrap()
}
#[test]
fn seam_strokes_stay_within_half_a_stroke_width_of_each_face() {
use kurbo::Shape;
for shade in [false, true] {
for angle in [110.0, 360.0] {
let mut n = profile_node();
n.appearance.effects.push(
crate::new_effect(
REVOLVE,
&json!({
"offset":20,"rotationX":-18,"segments":24,"cap":true,
"shade":shade,"angle":angle
}),
)
.unwrap(),
);
for art in [revolve_art(&n).unwrap(), expand_revolve_art(&n).unwrap()] {
let mut checked = 0;
art.walk(&mut |face| {
let Some(path) = face.path_data() else { return };
for item in &face.appearance.items {
let AppearanceItem::Stroke(stroke) = item else { continue };
let bounds = path.bounds().unwrap().inflate(stroke.width / 2.0 + 0.005, stroke.width / 2.0 + 0.005);
let ink = crate::stroke::line_outline(&path.to_bezpath(), stroke, stroke.width, 0.001).bounding_box();
assert!(
bounds.contains_rect(ink),
"seam protrudes beyond its overlap: {ink:?}, {bounds:?}, shade {shade}, sweep {angle}"
);
checked += 1;
}
});
assert!(checked > 20);
}
}
}
}
#[test]
fn smooth_shading_is_automatic_and_non_solid_paints_keep_their_base_paint() {
let mut n = profile_node();
n.appearance.effects.push(fx());
assert!(!n.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
let smooth = revolve_art(&n).unwrap();
assert!(
smooth.children().unwrap()[0].children().unwrap().iter().any(|face| {
matches!(face.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if matches!(fill.paint, Paint::Gradient(_)))
})
);
n.appearance.effects[0].params["smoothShading"] = json!(false);
assert_eq!(revolve_art(&n), Some(smooth.clone()));
n.appearance.effects[0].params["smoothShading"] = json!(true);
assert_eq!(revolve_art(&n), Some(smooth));
use vectorcraft_doc::color::{Gradient, GradientPaint};
let base = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
n.appearance.set_fill(base.clone());
let art = revolve_art(&n).unwrap();
let faces = art.children().unwrap()[0].children().unwrap();
assert!(faces.iter().all(|f| matches!(f.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if fill.paint == base)));
assert!(faces.iter().any(|f| f.appearance.items.iter().any(|item| {
matches!(item, AppearanceItem::Fill(fill) if matches!(&fill.paint, Paint::Gradient(g) if g.gradient.stops.iter().all(|s| s.color == Color::BLACK) && g.gradient.stops[0].opacity != g.gradient.stops[1].opacity))
})));
assert_eq!(expand_revolve_art(&n), Some(art), "unknown paint opacity keeps complete surfaces");
}
#[test]
fn missing_cap_parameter_defaults_on_and_explicit_false_restores_open_ends() {
let mut n = profile_node();
let mut effect = fx();
effect.params["offset"] = json!(20);
effect.params["rotationX"] = json!(-35);
effect.params.as_object_mut().unwrap().remove("cap");
n.appearance.effects.push(effect);
let capped = revolve_art(&n).unwrap();
n.appearance.effects[0].params["cap"] = json!(false);
let uncapped = revolve_art(&n).unwrap();
assert_ne!(uncapped, capped);
n.appearance.effects[0].params["cap"] = json!(true);
assert_eq!(revolve_art(&n), Some(capped));
n.appearance.effects[0].params["cap"] = json!(false);
assert_eq!(revolve_art(&n), Some(uncapped));
}
#[test]
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
let mut n = profile_node();
let before = n.path_data().unwrap().clone();
+75 -10
View File
@@ -3,7 +3,7 @@
use serde_json::Value;
use vectorcraft_geom::kurbo;
use vectorcraft_geom::shapes::{self, KAPPA};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners};
use crate::util::*;
@@ -24,15 +24,21 @@ pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData {
}
}
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`.
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`. A corner
/// whose anchor carries a handle (where a curve meets a line, or two curves meet) gets Live
/// Corners' round cut instead ([`corners::cut_corners`], #1144): it meets each side `radius` from
/// the corner, measured along the side, and trims a curved side to what is left of it.
pub fn round_corners(path: &PathData, radius: f64) -> PathData {
if radius <= 0.0 {
return path.clone();
}
let mut subs = vec![];
// Per anchor of the result: an anchor with a handle, kept as it was, for the cut below.
let mut with_handle = vec![];
for sp in &path.subpaths {
let n = sp.anchors.len();
if n < 3 && (n != 2 || sp.closed) {
with_handle.extend(sp.anchors.iter().map(|a| a.has_in() || a.has_out()));
subs.push(sp.clone());
continue;
}
@@ -41,6 +47,7 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
let a = sp.anchors[i];
let interior = sp.closed || (i > 0 && i + 1 < n);
if !interior || a.has_in() || a.has_out() {
with_handle.push(a.has_in() || a.has_out());
out.push(a);
continue;
}
@@ -52,12 +59,14 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
let (vp, vn) = (tp - a.p, tn - a.p);
let (lp, ln) = (vp.hypot(), vn.hypot());
if lp < 1e-9 || ln < 1e-9 {
with_handle.push(false);
out.push(a);
continue;
}
let (up, un) = (vp / lp, vn / ln);
// Nearly straight: nothing to round.
if up.dot(un) < -0.9999 {
with_handle.push(false);
out.push(a);
continue;
}
@@ -65,12 +74,36 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
let pa = a.p + up * d;
let pb = a.p + un * d;
let k = d * KAPPA;
with_handle.extend([false, false]);
out.push(Anchor::with_handles(pa, pa, pa - up * k));
out.push(Anchor::with_handles(pb, pb - un * k, pb));
}
subs.push(SubPath::new(out, sp.closed));
}
PathData::new(subs)
let rounded = PathData::new(subs);
// The corners left: those whose anchor has a handle. Their cut reaches `radius` along each
// side, as above; setback = r · (1 + cos) / sin, solved for the arc's radius r.
let mut radii = vec![0.0; with_handle.len()];
for c in corners::path_corners(&rounded) {
if with_handle.get(c.index) == Some(&true)
&& let Some(r) = radii.get_mut(c.index)
{
*r = radius * c.sin() / (1.0 + c.cos());
}
}
if radii.iter().all(|r| *r == 0.0) {
return rounded;
}
let (mut out, sources) = corners::cut_corners(&rounded, &radii, &[]);
// `cut_corners` starts a closed subpath whose first corner it cuts at the end of that cut;
// start at its start instead, as the rounding above does, so what depends on where a path
// starts (dashes, arrowheads after the effect) doesn't move.
for (sp, from) in out.subpaths.iter_mut().zip(&sources) {
if sp.closed && sp.anchors.len() > 1 && from.first() == from.last() {
sp.anchors.rotate_right(1);
}
}
out
}
fn flatten(path: &PathData) -> Vec<Vec<Point>> {
@@ -112,14 +145,19 @@ fn crosses_outline(p: Point, q: Point, polys: &[Vec<Point>]) -> bool {
}
/// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region
/// of the shape, outlined to `strokeWidth` so the fill paints it.
/// of the shape, outlined to `strokeWidth` so the fill paints it. `overlapVariation` moves each
/// line's ends along it by up to that many points, `spacingVariation` moves them across it, and
/// each turn's curviness moves from `curviness` by up to `curvinessVariation` percent.
pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
let angle = num(p, "angle", 30.0).to_radians();
let overlap = num(p, "overlap", 0.0);
let width = num(p, "strokeWidth", 3.0).clamp(0.01, 1000.0);
let curvy = num(p, "curviness", 5.0).clamp(0.0, 100.0) / 100.0;
let curviness = num(p, "curviness", 5.0).clamp(0.0, 100.0);
let curvy = curviness / 100.0;
let curvy_var = num(p, "curvinessVariation", 0.0).clamp(0.0, 100.0);
let spacing = num(p, "spacing", 5.0).max(0.1);
let variation = num(p, "variation", 0.5).clamp(0.0, 100.0);
let along = num(p, "overlapVariation", 0.5).clamp(0.0, 100.0);
let across = num(p, "spacingVariation", 0.5).clamp(0.0, 100.0);
let seed = seed(p);
let c = b.center();
let to_local = Affine::translate(c.to_vec2()) * Affine::rotate(angle) * Affine::translate(-c.to_vec2());
@@ -140,6 +178,8 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
// stays inside: one chain, as before.
struct Chain {
pts: Vec<Point>,
// Each point's curviness noise in [-1, 1].
bends: Vec<f64>,
end: Point,
row: u64,
}
@@ -160,10 +200,12 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
let spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
let open = chains.len();
for (a, bx) in spans {
let jitter = |k: u64| variation * noise(seed, row, k, 3);
let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3)));
let jitter = |k: u64, v: f64| v * noise(seed, row, k, 3);
let (l, r) = (Point::new(a + jitter(0, along), y + jitter(1, across)), Point::new(bx + jitter(2, along), y + jitter(3, across)));
let even = row.is_multiple_of(2);
let pair = if even { [l, r] } else { [r, l] };
let bend = [noise(seed, row, 4, 3), noise(seed, row, 5, 3)];
let bend = if even { bend } else { [bend[1], bend[0]] };
// Un-jittered ends of the span in drawing order.
let (start, end) = if even { (Point::new(a, y), Point::new(bx, y)) } else { (Point::new(bx, y), Point::new(a, y)) };
// The nearest chain of the previous row that this span can join without leaving
@@ -176,9 +218,10 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
Some(k) => {
let c = &mut chains[k];
c.pts.extend(pair);
c.bends.extend(bend);
(c.end, c.row) = (end, row);
}
None => chains.push(Chain { pts: pair.to_vec(), end, row }),
None => chains.push(Chain { pts: pair.to_vec(), bends: bend.to_vec(), end, row }),
}
}
row += 1;
@@ -187,7 +230,15 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
let lines: Vec<SubPath> = chains
.into_iter()
.filter(|c| c.pts.len() >= 2)
.map(|c| if curvy > 0.0 { catmull_rom(&c.pts, false, curvy * 2.0) } else { SubPath::polyline(&c.pts, false) })
.map(|c| {
if curvy_var > 0.0 {
varied_curves(&c.pts, &c.bends, curviness, curvy_var)
} else if curvy > 0.0 {
catmull_rom(&c.pts, false, curvy * 2.0)
} else {
SubPath::polyline(&c.pts, false)
}
})
.collect();
if lines.is_empty() {
return PathData::default();
@@ -198,3 +249,17 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05);
PathData::from_bezpath(&outline)
}
/// A scribble line through `pts` whose curviness at each point is `curviness` percent plus
/// `variation` percent times that point's `bends` noise (in -1..1), kept within 0 to 100 percent.
fn varied_curves(pts: &[Point], bends: &[f64], curviness: f64, variation: f64) -> SubPath {
// A tension of 6 makes each handle the whole Catmull-Rom tangent; each point then scales its
// own to the tension `catmull_rom` takes for that curviness (curviness / 100 × 2).
let mut line = catmull_rom(pts, false, 6.0);
for (a, bend) in line.anchors.iter_mut().zip(bends) {
let k = (curviness + variation * bend).clamp(0.0, 100.0) / 300.0;
a.h_in = a.p + (a.h_in - a.p) * k;
a.h_out = a.p + (a.h_out - a.p) * k;
}
line
}
+336 -2
View File
@@ -1,6 +1,6 @@
use serde_json::{Value, json};
use vectorcraft_doc::Effect;
use vectorcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes};
use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
@@ -8,6 +8,26 @@ fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// Every image under `n`, in document order (its placement's x).
fn image_xs(n: &Node, out: &mut Vec<f64>) {
if let NodeKind::Image(im) = &n.kind {
out.push(im.xf.as_coeffs()[4]);
}
for c in n.children().into_iter().flatten() {
image_xs(c, out);
}
}
/// Every symbol instance under `n`, in document order (its placement's x).
fn instance_xs(n: &Node, out: &mut Vec<f64>) {
if let NodeKind::SymbolInstance { xf, .. } = &n.kind {
out.push(xf.as_coeffs()[4]);
}
for c in n.children().into_iter().flatten() {
instance_xs(c, out);
}
}
fn square() -> PathData {
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))
}
@@ -224,12 +244,186 @@ fn stacked_transform_copies_stay_within_the_anchor_budget() {
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
}
/// #2042: stacked Transform effects multiply their image copies as they do their anchors; the
/// image nodes they make are bounded, whatever each one's count.
#[test]
fn stacked_transform_copies_stay_within_the_image_budget() {
use std::sync::Arc;
use vectorcraft_doc::{ImageObject, NodeId};
let id = NodeId(9);
let image = || {
Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
)
};
let count = |art: &Node| {
let mut xs = vec![];
image_xs(art, &mut xs);
xs.len()
};
let m = Affine::translate((70.0, 0.0));
let mut art = Node::group(id, vec![Arc::new(image())]);
let mut counts = vec![];
for _ in 0..3 {
crate::reshape::transform_placed_within(&mut art, m, 1000, 100);
counts.push(count(&art));
}
assert_eq!(counts, [100, 100, 100], "99 copies, then no room for a copy of them");
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
let mut art = Node::group(id, vec![Arc::new(image())]);
crate::reshape::transform_placed_within(&mut art, m, 24, 100);
assert_eq!(count(&art), 25);
let mut g = Node::group(id, vec![Arc::new(image())]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000})));
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
}
#[test]
fn transform_copies_stop_before_they_overflow() {
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
assert!(all_finite(&out) && out.subpaths.len() < 10, "{}", out.subpaths.len());
}
/// #2023: a Transform effect on a group moves, scales and copies its images like its paths: the
/// map goes into each image's placement, and every copy is an image node of its own.
#[test]
fn transform_on_a_group_moves_and_copies_its_images() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeId, NodeKind};
let id = NodeId(9);
let sq = Node::path(id, square(), Appearance::default());
let image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
);
let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
let art = evaluate_container(&g).unwrap();
let mut xs = vec![];
image_xs(&art, &mut xs);
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [10.0, 80.0, 150.0], "the image and its two copies, 70 pt apart");
// Without copies the image itself takes the transform, as a path does.
let mut g = Node::group(
id,
vec![Arc::new(Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
))],
);
g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8})));
let art = evaluate_container(&g).unwrap();
let mut xs = vec![];
image_xs(&art, &mut xs);
// The bounds are the image itself, so the bottom-right reference is its own: it halves in place.
assert_eq!(xs, [30.0], "scaled about its own bottom-right corner");
}
/// #2023: a Transform effect on a group moves and copies its symbol instances as it does its paths
/// and images: the map goes into each instance's placement, and every copy is an instance of its
/// own.
#[test]
fn transform_on_a_group_moves_and_copies_its_symbol_instances() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
let id = NodeId(9);
let instance = || Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::translate((5.0, 40.0)) });
let xs = |art: &Node| {
let mut xs = vec![];
instance_xs(art, &mut xs);
xs.sort_by(f64::total_cmp);
xs
};
let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart");
// Without copies the instance itself takes the transform, as a path does.
let mut g = Node::group(id, vec![Arc::new(instance())]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70})));
assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]);
}
/// Images and symbol instances share the copy budget: copies of one leave less room for the other.
#[test]
fn images_and_symbol_instances_share_the_copy_budget() {
use std::sync::Arc;
use vectorcraft_doc::{ImageObject, NodeId};
let id = NodeId(9);
let image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
);
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
let mut art = Node::group(id, vec![Arc::new(image), Arc::new(instance)]);
crate::reshape::transform_placed_within(&mut art, Affine::translate((70.0, 0.0)), 1000, 100);
let (mut images, mut instances) = (vec![], vec![]);
image_xs(&art, &mut images);
instance_xs(&art, &mut instances);
// The image takes the 98 copies there is room for; none is left for the instance.
assert_eq!((images.len(), instances.len()), (99, 1));
}
/// As a path's do, a Transform effect's copies of an image stop before one reaches further than
/// `MAX_REACH` from the origin.
#[test]
fn transform_copies_of_images_stop_at_the_reach_limit() {
use std::sync::Arc;
use vectorcraft_doc::{ImageObject, NodeId};
let id = NodeId(9);
let image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
);
let mut g = Node::group(id, vec![Arc::new(image)]);
// The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9.
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000})));
let mut xs = vec![];
image_xs(&evaluate_container(&g).unwrap(), &mut xs);
assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach");
assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9));
}
#[test]
fn pucker_bloat_moves_anchors_in_opposite_directions() {
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
@@ -378,6 +572,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();
@@ -400,6 +606,66 @@ fn round_corners_replaces_corners() {
assert_eq!(run("stylize.roundCorners", json!({}), &el), el);
}
/// A D: a straight back, a curved bowl, and two corners where the bowl meets the straight
/// sides (the anchors there carry the bowl's handles).
fn d_shape() -> PathData {
use vectorcraft_geom::Anchor;
let p = Point::new;
PathData::new(vec![SubPath::new(
vec![
Anchor::corner(p(0.0, 0.0)),
Anchor::with_handles(p(50.0, 0.0), p(50.0, 0.0), p(80.0, 20.0)),
Anchor::with_handles(p(50.0, 100.0), p(80.0, 80.0), p(50.0, 100.0)),
Anchor::corner(p(0.0, 100.0)),
],
true,
)])
}
#[test]
fn round_corners_also_rounds_where_a_curve_meets_a_line() {
let d = d_shape();
let out = run("stylize.roundCorners", json!({"radius": 8}), &d);
// All four corners are cut, the two curve-line ones too: two anchors each.
assert_eq!(out.anchor_count(), 8);
assert!(all_finite(&out));
// Every cut leaves both sides smoothly: no corner is left anywhere.
assert!(vectorcraft_geom::corners::path_corners(&out).is_empty(), "{out:?}");
let (before, after) = (d.to_bezpath().area().abs(), out.to_bezpath().area().abs());
assert!(after < before && after > before - 4.0 * 8.0 * 8.0, "{before} -> {after}");
// The cut at a curve-line corner meets the straight side 8 from the corner.
let p = Point::new;
assert!(out.subpaths[0].anchors.iter().any(|a| a.p.distance(p(42.0, 0.0)) < 1e-9), "{out:?}");
}
#[test]
fn round_corners_keeps_straight_corners_as_they_were() {
// The D's straight corners are rounded as before, next to the new cuts: same anchors, same
// quarter-circle handles, and the path still starts at its first corner's rounding.
use vectorcraft_geom::Anchor;
let p = Point::new;
let k = 8.0 * shapes::KAPPA;
let out = stylize::round_corners(&d_shape(), 8.0);
let a = &out.subpaths[0].anchors;
assert_eq!(a[0], Anchor::with_handles(p(0.0, 8.0), p(0.0, 8.0), p(0.0, 8.0 - k)));
assert_eq!(a[1], Anchor::with_handles(p(8.0, 0.0), p(8.0 - k, 0.0), p(8.0, 0.0)));
// A path of straight corners only is rounded exactly as before.
assert_eq!(stylize::round_corners(&square(), 10.0).subpaths[0].anchors.len(), 8);
}
#[test]
fn round_corners_keeps_where_a_closed_path_starts() {
// The D starting at a curve-line corner: the result starts where that corner's cut begins,
// on the side coming from the previous corner, as a straight corner's rounding does.
let mut d = d_shape();
d.subpaths[0].anchors.rotate_left(1);
let out = stylize::round_corners(&d, 8.0);
let start = out.subpaths[0].anchors[0].p;
assert!(start.distance(Point::new(42.0, 0.0)) < 1e-9, "{start:?}");
// An infinite radius rounds as much as fits, curve-line corners too.
assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9));
}
#[test]
fn twist_fixes_the_outer_circle_and_rotates_inside() {
let out = run("distort.twist", json!({"angle": 0}), &square());
@@ -436,6 +702,59 @@ fn scribble_produces_filled_outline_inside_bounds() {
assert_eq!(out, run("stylize.scribble", json!({}), &square()));
}
#[test]
fn scribble_variations_are_separate() {
// At 0° the lines run along x: the Path Overlap variation moves their ends in x only, the
// Spacing variation moves them in y only, and the Curviness variation curves some of the turns.
let base =
json!({"angle": 0, "curviness": 0, "strokeWidth": 0.5, "spacing": 10, "overlapVariation": 0, "spacingVariation": 0, "curvinessVariation": 0});
let with = |key: &str, v: f64| {
let mut p = base.clone();
p[key] = json!(v);
run("stylize.scribble", p, &square())
};
let plain = run("stylize.scribble", base.clone(), &square());
let b = plain.bounds().unwrap();
let moved = |a: f64, b: f64| (a - b).abs() > 0.5;
let along = with("overlapVariation", 4.0).bounds().unwrap();
assert!(close(along.y0, b.y0, 0.06) && close(along.y1, b.y1, 0.06), "{along:?} {b:?}");
assert!(moved(along.x0, b.x0) || moved(along.x1, b.x1), "{along:?} {b:?}");
let across = with("spacingVariation", 4.0).bounds().unwrap();
assert!(close(across.x0, b.x0, 0.06) && close(across.x1, b.x1, 0.06), "{across:?} {b:?}");
assert!(moved(across.y0, b.y0) || moved(across.y1, b.y1), "{across:?} {b:?}");
assert_ne!(with("curvinessVariation", 50.0), plain);
// On a curviness of its own, the variation changes how much each turn curves.
let mut p = base.clone();
p["curviness"] = json!(20);
let even = run("stylize.scribble", p.clone(), &square());
p["curvinessVariation"] = json!(20);
assert_ne!(run("stylize.scribble", p, &square()), even);
}
#[test]
fn scribble_reads_its_earlier_single_variation() {
// Before the separate variations, one `variation` moved the ends both along and across.
let old = run("stylize.scribble", json!({"variation": 3}), &square());
assert_eq!(old, run("stylize.scribble", json!({"overlapVariation": 3, "spacingVariation": 3}), &square()));
assert_eq!(upgrade_params("stylize.scribble", &json!({"variation": 3})), json!({"overlapVariation": 3, "spacingVariation": 3}));
// A current key given with it wins.
assert_eq!(
upgrade_params("stylize.scribble", &json!({"variation": 3, "spacingVariation": 1})),
json!({"overlapVariation": 3, "spacingVariation": 1})
);
assert!(default_params("stylize.scribble").unwrap().get("variation").is_none());
}
#[test]
fn scribble_angle_is_stored_above_minus_180_and_up_to_180() {
for (given, stored) in [(270.0, -90.0), (-180.0, 180.0), (180.0, 180.0), (540.0, 180.0), (-270.0, 90.0), (-200.0, 160.0)] {
assert_eq!(new_effect("stylize.scribble", &json!({"angle": given})).unwrap().params["angle"].as_f64(), Some(stored), "{given}");
}
// An angle in range is kept as given; other effects' angles are not changed.
assert_eq!(upgrade_params("stylize.scribble", &json!({"angle": 45})), json!({"angle": 45}));
assert_eq!(upgrade_params("distort.twist", &json!({"angle": 270})), json!({"angle": 270}));
}
#[test]
fn effects_chain_in_order_and_skip_hidden_and_raster() {
let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))];
@@ -462,11 +781,12 @@ fn raster_effects_parse_and_outset() {
let r = raster_effects(&list);
assert_eq!(r.len(), 5);
match &r[0] {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => {
assert_eq!(*mode, vectorcraft_doc::color::BlendMode::Multiply);
assert!(close(*opacity as f64, 0.5, 1e-6));
assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0));
assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]);
assert_eq!(*darkness, None);
}
other => panic!("{other:?}"),
}
@@ -475,6 +795,20 @@ fn raster_effects_parse_and_outset() {
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
}
#[test]
fn drop_shadow_darkness_replaces_its_color_when_chosen() {
let darkness = |params: Value| match raster_effects(&[fx("stylize.dropShadow", params)]).as_slice() {
[RasterFx::DropShadow { darkness, .. }] => *darkness,
other => panic!("{other:?}"),
};
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 60})), Some(0.6));
assert_eq!(darkness(json!({"shade": "darkness"})), Some(1.0));
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 250})), Some(1.0));
// Color is the default: a Darkness value alone leaves the shadow its color.
assert_eq!(darkness(json!({"darkness": 60})), None);
assert_eq!(darkness(json!({})), None);
}
#[test]
fn blend_mode_names() {
use vectorcraft_doc::color::BlendMode;
+6
View File
@@ -8,6 +8,12 @@ description = "VectorCraft engine: sessions, history, selection, the command reg
[lints]
workspace = true
[features]
default = ["bundled-fonts"]
# The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at
# run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`.
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
[dependencies]
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
+7 -7
View File
@@ -22,7 +22,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
use vectorcraft_geom::Rect;
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::opacitymask::percent;
use super::paint::paint_from;
use super::*;
@@ -294,15 +294,15 @@ pub(crate) fn subject_roots(s: &Session) -> Result<Vec<NodeId>> {
}
}
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the
/// [`subject_roots`]; with `target: "contents"` the painted objects inside the groups and layers
/// among them ([`leaf_targets`]).
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too; an id inside
/// another given one is dropped), else the [`subject_roots`]; with `target: "contents"` the painted
/// objects inside the groups and layers among them ([`leaf_targets`]).
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let roots = if p.get("ids").is_none() && p.get("id").is_none() {
subject_roots(s)?
} else {
let d = &s.doc()?.doc;
targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect()
outermost(d, targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect())
};
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
@@ -560,7 +560,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
}
};
if let Some(pa) = &paint {
*pp = super::gradient::place_paint(pa, p, bounds);
*pp = super::gradient::place_paint(&super::paint::keep_pattern_xf(pa, p, Some(&*pp)), p, bounds);
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = percent(o);
@@ -745,7 +745,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let selected = match ids_param(p, "ids") {
Some(v) => v,
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
};
// Reversed, the first selected object's attributes go onto the clicked object.
+24 -5
View File
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{ExportAsset, NodeId};
use super::edit::{roots_of, selected_roots};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings};
use super::*;
/// The longest asset name kept (characters).
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Collect for Export",
["Object", "Collect for Export"],
None,
"{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
"{ids?: [id…] (default: the selected objects; a layer id collects the art under it, including in its sublayers), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
has_doc,
add
),
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assets",
[],
None,
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order",
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order",
has_doc,
list
),
@@ -78,13 +78,32 @@ fn clean_name(name: &str) -> Option<String> {
(!n.is_empty()).then_some(n)
}
/// 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.
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() {
for c in kids {
art_under(doc, c.id, out);
}
}
return;
}
out.push(id);
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.add";
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) {
// An explicit list is authoritative: never silently drop malformed or stale ids.
let ids = checked_ids_param(s, p, "ids", C)?;
let d = &s.doc()?.doc;
roots_of(d, d.paint_order(ids))
let mut art = Vec::new();
for id in &ids {
art_under(d, *id, &mut art);
}
roots_of(d, d.paint_order(art))
} else {
selected_roots(s)?
};
@@ -161,7 +180,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
.assets
.iter()
.map(|a| {
let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y));
let bounds = export_crop(d, &a.nodes);
json!({
"id": a.id,
"name": a.name,
+127 -17
View File
@@ -11,10 +11,10 @@ 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};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::*;
use crate::EngineError;
@@ -95,22 +95,31 @@ pub fn specs() -> Vec<CommandSpec> {
brush_freehand
),
// ----- symbols -----
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h]}], 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?} make a symbol from the selection and replace it with an instance → {name, id}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
has_selection,
symbol_new
),
cmd!(
"symbol.options",
"Symbol Options…",
[],
None,
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
has_doc,
symbol_options
),
cmd!(
"symbol.place",
"Place Symbol Instance",
[],
None,
"{name?, x?, y?} place an instance centred at (x, y) (default: artboard centre; name default: current) → {id}",
"{name?, x?, y?} place an instance so its registration point is at (x, y) (default: artboard centre; name default: current) → {id}",
has_doc,
symbol_place
),
@@ -198,11 +207,11 @@ fn ids_json(ids: &[NodeId]) -> Value {
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(mut v) => {
// Explicit ids: only existing, non-layer objects.
// Explicit ids: only existing, non-layer objects, none inside another of them.
let d = &s.doc()?.doc;
v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer()));
v.dedup();
Ok(v)
Ok(outermost(d, v))
}
None => selected_roots(s),
}
@@ -511,11 +520,39 @@ fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) {
}
}
/// Instance transform for a symbol of natural size `(w, h)` centred at `c`.
fn place_xf(c: Point, (w, h): (f64, f64)) -> Affine {
/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`.
fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
let c = at - reg.offset_from_center(w, h);
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<Option<SymbolRegistration>> {
match p.get("registration") {
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}`"))),
}
}
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)));
}
}
/// Normalise art for a symbol definition: returns (art, natural size, centre in the document).
fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?;
@@ -553,7 +590,13 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
.iter()
.map(|sym| {
let (w, h) = natural_size(d, &sym.name);
json!({"name": sym.name, "instances": instances_of(d, Some(&sym.name)).len(), "size": [w, h]})
json!({
"name": sym.name,
"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(),
})
})
.collect();
Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) }))
@@ -576,20 +619,78 @@ 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, C)?.unwrap_or(SymbolRegistration::CENTER);
let export = export_type_param(p, C)?.unwrap_or_default();
// 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| {
let art = d.reid(&art);
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.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
let id = replace_with_instance(d, &ids, &name, place)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "name": name, "id": id.0 }))
Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() }))
}
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 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() {
return Err(bad(C, "pass newName, registration and/or exportType"));
}
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);
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);
}
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(),
}))
}
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
@@ -606,12 +707,13 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let name = resolve_symbol(d, p, "symbol.place")?;
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let parent = s.doc()?.target_parent()?;
let id = s.edit("Place Symbol Instance", |d, sel| {
let id = d.alloc_id();
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?;
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?;
sel.set([id]);
Ok(id)
})?;
@@ -720,7 +822,8 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
*xf *= fix;
}
}
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
let reg = d.symbol_registration(&name);
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
sel.set([id]);
Ok(id)
})?;
@@ -743,6 +846,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);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.remove(CURRENT_SYMBOL);
}
@@ -758,13 +863,17 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
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);
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);
Ok(())
})?;
Ok(json!({ "name": new }))
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
}
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
@@ -882,13 +991,14 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
placed.push(c);
}
let count = placed.len();
let reg = s.doc()?.doc.symbol_registration(&name);
let id = s.edit("Symbol Sprayer", |d, sel| {
let mut nodes = vec![];
for (i, c) in placed.iter().enumerate() {
let (_, _, js) = jitter(i + 7, *c);
let k = scale * (1.0 + js * 0.1);
let id = d.alloc_id();
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size) * Affine::scale(k) }));
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) }));
}
let gid = match set {
Some(g) => {
+2 -2
View File
@@ -24,7 +24,7 @@ use vectorcraft_tools::builder::{
};
use vectorcraft_trace as tr;
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
use super::*;
@@ -192,7 +192,7 @@ fn root_ids(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if ids.is_empty() {
return Err(EngineError::Other("select objects, not layers".into()));
}
Ok(sorted_roots(d, &ids))
Ok(outermost(d, sorted_roots(d, &ids)))
}
fn points_param(p: &Value, key: &str) -> Option<Vec<Point>> {
+21 -9
View File
@@ -80,7 +80,8 @@ pub struct Clipboard {
pub para_styles: Vec<TextStyleDef>,
/// Brushes the objects' strokes use.
pub brushes: Vec<Brush>,
/// The artboard the objects were copied from: Paste on All Artboards keeps their offset to it.
/// The artboard the objects were copied from (none when they lie on no artboard): 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>>,
@@ -109,8 +110,14 @@ impl Clipboard {
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));
let board = bounds.and_then(|b| d.artboard_at(b.center())).unwrap_or(0);
Self { source_artboard: d.artboards.get(board).map(|a| a.rect), source_layers, source_doc: Some(st.uid), ..Self::gather(d, nodes) }
// 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 {
source_artboard: board.and_then(|i| d.artboards.get(i)).map(|a| a.rect),
source_layers,
source_doc: Some(st.uid),
..Self::gather(d, nodes)
}
}
/// Every object of `src` (each top-level layer's contents) with its resources, for Place and
@@ -168,15 +175,13 @@ impl Clipboard {
d.fix_next_id();
d
}
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
if self.clipboard.is_empty() {
/// The objects as standalone SVG (`None` when there are none).
pub fn svg(&self) -> Option<String> {
if self.is_empty() {
return None;
}
let d = self.clipboard.to_document();
let d = self.to_document();
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = super::rasterfx::flatten_pixel_effects(&d);
Some(vectorcraft_svg::export(
@@ -186,6 +191,13 @@ impl Session {
}
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
self.clipboard.svg()
}
}
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "svg": s.clipboard_svg() }))
}
+89 -35
View File
@@ -71,13 +71,19 @@ pub const FILE_HEAD: usize = 4096;
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
let f = fileio::detect(name, head)?;
let (asked, mime) = match f.id {
"svg" => (SVG, SVG),
// `detect` names gzip data SVGZ (a compressed native file aside); `clipboard.importSvg`
// reads SVGZ data.
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
"pdf" => (PDF, PDF),
"eps" => (EPS, EPS),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
"emf" | "wmf" => (EMF, EMF),
_ if f.raster => (BITMAP, f.mime),
_ => return None,
@@ -85,6 +91,25 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
mimes.contains(&asked).then_some(mime)
}
/// Does gzip data `head` (a file's first bytes, often a stream cut short) unpack to SVG markup?
/// The first [`FILE_HEAD`] bytes it unpacks to are read as UTF-16 after a UTF-16 byte order mark,
/// else as UTF-8, and checked with [`looks_like_svg`].
fn unpacks_to_svg(head: &[u8]) -> bool {
use std::io::Read as _;
let mut raw = Vec::with_capacity(FILE_HEAD);
// A stream cut short ends in an error after the bytes that unpacked: judge those.
let _ = flate2::read::GzDecoder::new(head).take(FILE_HEAD as u64).read_to_end(&mut raw);
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| -> String {
char::decode_utf16(rest.as_chunks::<2>().0.iter().map(|u| unit(*u))).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
let text = match raw.as_slice() {
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
_ => String::from_utf8_lossy(&raw).into_owned(),
};
looks_like_svg(&text)
}
/// One representation of the clipboard's contents.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Flavour {
@@ -192,11 +217,10 @@ fn type_only(n: &Node) -> bool {
}
}
impl Session {
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
/// only).
pub fn clipboard_text(&self) -> Option<String> {
let nodes = &self.clipboard.nodes;
impl Clipboard {
/// The copied type objects' text, one object per line (`None` unless it holds type only).
pub fn text(&self) -> Option<String> {
let nodes = &self.nodes;
if nodes.is_empty() || !nodes.iter().all(type_only) {
return None;
}
@@ -211,64 +235,94 @@ impl Session {
Some(lines.join("\n"))
}
/// The clipboard as a document whose one artboard is the objects' visual bounds (`None` when
/// it is empty or has no extent).
fn clipboard_cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
let b = self.clipboard.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
Some((fileio::with_single_artboard(self.clipboard.to_document(), b, "Clipboard"), b))
/// The objects as a document whose one artboard is their visual bounds (`None` when there are
/// none or they have no extent).
fn cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
let b = self.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
/// there are none.
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
const C: &str = "clipboard.exportPng";
let Some((doc, b)) = self.clipboard_cropped() else { return Ok(None) };
let Some((doc, b)) = self.cropped() else { return Ok(None) };
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h)))
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
let Some((doc, _)) = self.clipboard_cropped() else { return Ok(None) };
/// A one-page PDF of the objects (without the native document inside); `None` when there are
/// none.
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
let Some((doc, _)) = self.cropped() else { return Ok(None) };
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// The MIME types of [`Self::clipboard_flavours`], without making them.
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
if self.clipboard.is_empty() {
/// The MIME types of [`Self::flavours`], without making them.
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
if self.is_empty() {
return vec![];
}
let (svg, pdf) = (self.prefs.copy_as_svg, self.prefs.copy_as_pdf);
let text = svg || self.clipboard.nodes.iter().all(type_only);
let text = svg || self.nodes.iter().all(type_only);
// Art without an area (a lone guide-like line) has no page or picture.
let area = self.clipboard.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
}
/// What Copy offers other apps, best first (see `clipboard.flavours`). A flavour that can't be
/// made (art too large to render) is left out; the PNG is scaled down to at most
/// [`COPY_PNG_SIDE`] pixels a side.
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
let types = self.clipboard_flavour_types();
let svg = types.contains(&SVG).then(|| self.clipboard_svg()).flatten();
/// What Copy offers other apps, best first, with Include SVG Code (`svg`) and On Copy: PDF
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
let types = self.flavour_types(svg, pdf);
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
let mut out = vec![];
for mime in types {
let data = match mime {
TEXT => self.clipboard_text().or_else(|| svg.clone()).map(String::into_bytes),
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
SVG => svg.clone().map(String::into_bytes),
PDF => self.clipboard_pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
_ => {
let side = self.clipboard.bounds().map_or(1.0, |b| b.width().max(b.height()));
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
self.clipboard_png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
}
};
out.extend(data.map(|data| Flavour { mime, data }));
}
out
}
}
impl Session {
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
/// only).
pub fn clipboard_text(&self) -> Option<String> {
self.clipboard.text()
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
self.clipboard.png(scale)
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
self.clipboard.pdf()
}
/// The MIME types of [`Self::clipboard_flavours`], without making them.
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
self.clipboard.flavour_types(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
}
/// What Copy offers other apps, best first (see `clipboard.flavours`): [`Clipboard::flavours`]
/// by the Clipboard Handling preferences.
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
self.clipboard.flavours(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
}
/// Replace the clipboard with `clip`, centred on `p`'s `center` (default: the first
/// artboard's centre) → the number of objects.
+6
View File
@@ -204,6 +204,12 @@ impl<'a> Recolor<'a> {
Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) }
}
/// A pass that maps solid colours and gradient stops with their swatch link and tint
/// (`links`), and meshes, images and patterns through `f`.
pub(crate) fn with_links(scope: Scope, f: &'a dyn Fn(Color) -> Color, links: &'a LinkMap<'a>) -> Self {
Self { links: Some(links), ..Self::new(scope, f) }
}
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
/// pattern swatches, and return the number of paints, meshes and images changed.
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
+2 -2
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Color Profile…",
[],
None,
"{path: .icc/.icm file} register an ICC profile (native only) → {name, kind}",
"{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}",
always,
load_profile
),
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Proof Setup",
["View", "Proof Setup"],
None,
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\"|\"tritanopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
always,
proof_setup
),
+9 -1
View File
@@ -95,6 +95,12 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
/// Insert a new object looking like `look` (drawing modes apply); select it.
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
insert_look(s, label, kind, look, name, true)
}
/// [`add_look`] that selects the new object only when `select` is set; otherwise the selection
/// stays as it was.
pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>, select: bool) -> Result<Value> {
let parent = s.doc()?.target_parent()?;
let mode = s.draw_mode;
let inside = s.draw_inside;
@@ -137,7 +143,9 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
d.insert(parent, usize::MAX, n)?;
}
}
sel.set([id]);
if select {
sel.set([id]);
}
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
+369
View File
@@ -0,0 +1,369 @@
//! Linked dimensions: generated groups share the existing text/path render and export pipeline.
use super::*;
use serde_json::{Value, json};
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"dimension.create",
"Create Dimension",
[],
None,
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
has_doc,
create
),
cmd!(
"dimension.update",
"Update Dimensions",
[],
None,
"{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
has_doc,
update
),
cmd!(
"dimension.applyToAll",
"Apply to All",
[],
None,
"{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}",
has_doc,
apply_to_all
),
]
}
fn error(s: impl ToString) -> EngineError {
EngineError::Other(s.to_string())
}
fn options(s: &Session, p: &Value) -> Result<DimensionOptions> {
let default = s.tool_options_of("dimension");
let value = p.get("options").cloned().unwrap_or_else(|| default.get("style").cloned().unwrap_or(json!({})));
DimensionOptions::from_params(value).map_err(error)
}
fn point(p: &Value, key: &str) -> Result<Point> {
let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 2).ok_or_else(|| error(format!("{key} must be [x,y]")))?;
let x = a.first().and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
let y = a.get(1).and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
Ok(Point::new(x, y))
}
fn distance(p: &Value, fallback: f64) -> Result<f64> {
match p.get("offset") {
Some(v) => v.as_f64().filter(|n| n.is_finite() && n.abs() <= 1e6).ok_or_else(|| error("Invalid dimension offset")),
None => Ok(fallback),
}
}
fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
let mode: DimensionMode =
p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default();
let source: Option<DimensionSource> = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?;
if let Some(source) = &source {
let doc = &s.doc()?.doc;
for id in source.nodes() {
if doc.ancestry(id).is_some_and(|path| path.iter().any(|id| doc.node(*id).is_some_and(|n| n.extras().dimension.is_some()))) {
return Err(error("Choose original artwork, not a dimension's generated paths"));
}
}
let compatible = matches!(
(mode, source),
(DimensionMode::Linear, DimensionSource::Segment { .. })
| (DimensionMode::Angular, DimensionSource::Angle { .. })
| (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. })
| (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. })
);
if !compatible {
return Err(error("Dimension mode does not match its source"));
}
}
let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO };
let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) };
let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
point(p, "vertex")?
} else {
Point::ZERO
};
let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
d.resolve(&s.doc()?.doc).map_err(error)?;
Ok(d)
}
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
let id = doc.alloc_id();
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false };
doc.dimensions = true;
group.extras_mut().dimension = Some(Box::new(dim));
doc.insert(parent, usize::MAX, group)?;
Ok(id)
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let dim = recipe(s, p)?;
let label = dim.label().map_err(error)?;
let parent = s.doc()?.insertion_parent();
s.note_journal("options", serde_json::to_value(&dim.options).map_err(error)?);
let id = s.edit("Create Dimension", |doc, sel| {
let id = insert(doc, parent, dim)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({"id":id.0,"label":label}))
}
fn dimension_of(doc: &Document, id: NodeId) -> Result<Dimension> {
doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions"))
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = match p.get("ids") {
Some(v) => serde_json::from_value(v.clone()).map_err(error)?,
None => s.doc()?.selection.objects.clone(),
};
if ids.is_empty() || ids.len() > 10_000 {
return Err(error("Select dimensions to update"));
}
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
s.edit("Update Dimensions", |doc, _| {
for id in &ids {
if !doc.is_editable(*id) {
return Err(error("Dimension is locked or hidden"));
}
let mut dim = dimension_of(doc, *id)?;
if let Some(o) = &o {
dim.options = o.clone();
}
dim.offset = distance(p, dim.offset)?;
dim.resolve(doc).map_err(error)?;
replace(doc, *id, dim)?;
}
Ok(())
})?;
Ok(json!({"ids":ids}))
}
fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let from = match p.get("id") {
Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?),
None => *st
.selection
.objects
.iter()
.find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some()))
.ok_or_else(|| error("Select a dimension first"))?,
};
let o = dimension_of(&st.doc, from)?.options;
let mut ids = vec![];
st.doc.walk(|n| {
if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
ids.push(n.id);
}
});
s.edit("Apply to All", |doc, _| {
for id in &ids {
let mut dim = dimension_of(doc, *id)?;
dim.options = o.clone();
dim.resolve(doc).map_err(error)?;
replace(doc, *id, dim)?;
}
Ok(())
})?;
Ok(json!({"ids":ids}))
}
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
let children = artwork(doc, &dim, &old)?;
if let Some(n) = doc.node_mut(id) {
n.kind = NodeKind::Group { children, clip: false };
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
n.extras_mut().dimension = Some(Box::new(dim));
}
Ok(())
}
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
/// measure. Returns at once in documents without dimensions.
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
if !doc.dimensions {
return Ok(());
}
let mut dimensions = Vec::new();
doc.walk(|n| {
if let Some(d) = &n.extras().dimension {
dimensions.push((n.id, (**d).clone()));
}
});
doc.dimensions = !dimensions.is_empty();
for (id, old) in dimensions {
let mut next = old.clone();
let moved = next.moved.take();
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
// A deleted/degenerate source has no valid measurement. Remove its annotation in
// this same undo step instead of keeping a silently wrong number.
doc.remove(id)?;
continue;
}
// Moved on its own (the art it measures stayed put): it keeps its new place.
if let Some(v) = moved
&& (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
{
next.place(next.handle() + v);
if next.resolve(doc).is_err() {
next = old.clone();
next.moved = None;
}
}
if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
replace(doc, id, next)?;
}
}
Ok(())
}
fn paint(c: [u8; 3]) -> Paint {
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
}
fn line(a: Point, b: Point) -> BezPath {
let mut p = BezPath::new();
p.move_to(a);
p.line_to(b);
p
}
fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
let (color, width, kind) =
if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) };
let mut a = Appearance::basic(Paint::None, paint(color), width);
if let Some(st) = a.stroke_mut() {
st.dash = match kind {
LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
LineType::Dotted => {
st.cap = vectorcraft_doc::LineCap::Round;
Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() })
}
LineType::Solid => None,
};
if arrows {
let head = match o.arrow_style {
ArrowStyle::Filled => Some(Arrowhead::Triangle),
ArrowStyle::Open => Some(Arrowhead::TriangleOpen),
ArrowStyle::Tick => Some(Arrowhead::Slash),
ArrowStyle::None => None,
};
st.start_arrow = head;
st.end_arrow = head;
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
}
}
a
}
/// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows.
fn along(o: &DimensionOptions) -> f64 {
match o.text_alignment {
TextAlignment::Left => 0.2,
TextAlignment::Center => 0.5,
TextAlignment::Right => 0.8,
}
}
fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<Node>>> {
d.validate().map_err(error)?;
let o = &d.options;
let label = d.label().map_err(error)?;
let mut paths: Vec<(BezPath, Appearance)> = Vec::new();
let mut angle = 0.0;
let text_at;
// Away from the object: past the dimension line; between: on the object's side of it.
let away = o.text_position == TextPosition::Away;
if d.mode == DimensionMode::Angular {
let start = (d.a - d.vertex).atan2();
let sweep = d.angle();
let r = d.offset.abs().max(12.0);
let on = |t: f64| d.vertex + Vec2::from_angle(start + sweep * t) * r;
let mut arc = BezPath::new();
arc.move_to(on(0.0));
for i in 1..=64 {
arc.line_to(on(i as f64 / 64.0));
}
paths.push((arc, appearance(o, false, true)));
if !o.hide_extensions {
for p in [d.a, d.b] {
let dir = (p - d.vertex) / (p - d.vertex).hypot();
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false)));
}
}
let dir = Vec2::from_angle(start + sweep * along(o));
let gap = o.text_offset + o.font_size * 0.5;
text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(o.font_size * 0.5) };
} else {
let delta = d.b - d.a;
let u = delta / delta.hypot();
let n = Vec2::new(-u.y, u.x);
let (a, b) = match d.mode {
DimensionMode::Radius => (d.a, d.b),
DimensionMode::Diameter => (d.a - delta, d.b),
_ => (d.a + n * d.offset, d.b + n * d.offset),
};
// The side of the line facing away from the object.
let outward = match d.mode {
DimensionMode::Linear => {
if d.offset < 0.0 {
-n
} else {
n
}
}
_ => n,
};
if d.mode == DimensionMode::Linear && !o.hide_extensions {
for (p, q) in [(d.a, a), (d.b, b)] {
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false)));
}
}
angle = u.atan2();
if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) {
angle += std::f64::consts::PI;
}
let shift = o.text_offset + o.font_size * 0.5;
text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift };
paths.push((line(a, b), appearance(o, false, true)));
}
let style = CharStyle {
font_family: o.font_family.clone(),
font_style: o.font_style.clone(),
size: o.font_size,
fill: paint(o.text_color),
..Default::default()
};
let mut text = TextObject::point(Point::ZERO, &label, style);
text.para.justify = vectorcraft_doc::Justify::Center;
super::typecmd::refresh_bounds(&mut text);
// Centre the label's box on its anchor point, along the line.
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
let middle = Affine::translate(-bounds.center().to_vec2());
text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle) * middle;
let mut children = Vec::new();
for (path, a) in paths {
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
children.push(Arc::new(Node::path(id, PathData::from_bezpath(&path), a)));
}
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
let mut node = Node::new(id, NodeKind::Text(Box::new(text)));
node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into();
children.push(Arc::new(node));
Ok(children)
}
+73 -9
View File
@@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
use super::edit::{duplicate_in, selected_roots};
use super::edit::{duplicate_in, outermost, selected_roots};
use super::*;
use crate::EngineError;
@@ -257,7 +257,22 @@ fn place(points: &mut Vec<(f64, f64, f64)>, mut pt: (f64, f64, f64), from_below:
/// points at other positions either side change in proportion (by the same amount from zero).
fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f64), new: (f64, f64, f64)) {
let t = points[i].0;
let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }.max(0.0);
let scale = |v: f64, o: f64, n: f64| {
let width = if o > 1e-9 {
let product = v * n;
if product.is_finite() {
// Keep the existing rounding, including small products that n/o would lose.
product / o
} else {
// With finite nonnegative factors, an overflowing product has both factors
// above one. Divide the larger first so this fallback cannot underflow.
(v.max(n) / o) * v.min(n)
}
} else {
v + n - o
};
width.max(0.0)
};
let before = (0..i).rev().find(|j| (points[*j].0 - t).abs() > SAME_T);
let after = (i + 1..points.len()).find(|j| (points[*j].0 - t).abs() > SAME_T);
for j in before.into_iter().chain(after) {
@@ -293,8 +308,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
let st = weighted_stroke(d, id)?;
let before = st.profile.clone();
let half = st.width / 2.0;
let mut prof = st.profile.take().unwrap_or_else(uniform);
let pt = (t, left / half, right / half);
if !half.is_finite() || half <= 0.0 || !pt.1.is_finite() || !pt.2.is_finite() {
return Err(bad(C, "width factors must be finite"));
}
let mut prof = st.profile.take().unwrap_or_else(uniform);
pos = match point_index(p, &prof, C)? {
Some(i) => {
let old = prof.points[i];
@@ -320,6 +338,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
i
}
};
// Validate on edit_counted's copy: a failed adjoining adjustment must not change
// the document, its dirty state or either side of its history.
if prof.points.iter().any(|p| !p.1.is_finite() || !p.2.is_finite()) {
return Err(bad(C, "width factors must be finite"));
}
st.profile = Some(prof);
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
})?;
@@ -683,7 +706,8 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "pins must be finite"));
}
let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
let ids =
if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
@@ -823,7 +847,7 @@ fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => Ok(v),
Some(v) => Ok(outermost(&s.doc()?.doc, v)),
None => selected_roots(s),
}
}
@@ -913,9 +937,18 @@ fn beyond_horizon() -> EngineError {
EngineError::Other("the pointer is beyond the plane's horizon".into())
}
/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments
/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]).
fn stores_attachments(d: &Document) -> bool {
!PerspectiveGrid::current(d).attached.is_empty()
}
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an
/// interaction whose document is still its snapshot, the duplicates are the copies the interaction
/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its
/// own ([`stores_attachments`]).
fn persp_edit(
s: &mut Session,
label: &str,
@@ -928,9 +961,20 @@ fn persp_edit(
return Err(EngineError::Other("select the objects to transform in perspective".into()));
}
let mut g = grid_of(&s.doc()?.doc);
let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None };
s.edit(label, |d, sel| {
g.adopt_stored_attachments(d);
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
let targets = match &kept {
// The document becomes the snapshot with the kept copies, not yet mapped, and the loop
// below maps only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?,
None => ids.to_vec(),
};
let mut first = None;
for id in &targets {
let (plane, depth) = match g.attachment_of(d, *id) {
@@ -1116,12 +1160,32 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
}
// In an interaction whose document is still its snapshot, the copies of the objects on the
// plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid
// lists attachments of its own (`stores_attachments`).
let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) {
let on = on_plane(&s.doc()?.doc);
super::object::preview_copies(s, &on, false)?
} else {
None
};
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
let on = on_plane(d);
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
ids = match &kept {
// The document becomes the snapshot with the kept copies, not yet moved, and the
// loop below moves only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None => {
let on = on_plane(d);
if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }
}
};
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
for id in &ids {
warp_checked(d, *id, &m)?;
+48 -5
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Info",
["Window", "Document Info"],
None,
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
has_doc,
info
),
@@ -50,7 +50,7 @@ pub const CATEGORIES: [(&str, &str); 11] = [
];
/// The object counts of `document.info` and their labels.
const OBJECT_LABELS: [(&str, &str); 17] = [
const OBJECT_LABELS: [(&str, &str); 18] = [
("paths", "Paths"),
("compoundPaths", "Compound Paths"),
("groups", "Groups"),
@@ -65,6 +65,7 @@ const OBJECT_LABELS: [(&str, &str); 17] = [
("blends", "Blends"),
("envelopes", "Envelopes"),
("repeats", "Repeats"),
("intertwines", "Intertwine Groups"),
("opacityMasks", "Opacity Masks"),
("liveEffects", "Objects with Effects"),
("guides", "Guides"),
@@ -128,7 +129,13 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>
rows
}
"objects" => {
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect()
let mut rows: Vec<(String, String)> =
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect();
// The paths' total length, in the document's units, right after their count.
if let Some(at) = rows.iter().position(|(label, _)| label == "Paths") {
rows.insert(at + 1, ("Path Length".into(), u.format(info["pathLength"].as_f64().unwrap_or(0.0))));
}
rows
}
"graphicStyles" => names("graphicStyleNames"),
"spotColors" => names("spotColors"),
@@ -229,6 +236,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
let roots: Vec<&Node> =
if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() };
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
// The paths' total length, as Document Info › Objects shows it (the length of a cut line, a
// strip of LEDs along the letters, #1958).
let mut path_length = 0.0;
let mut fonts = BTreeSet::new();
let mut images: BTreeMap<String, Value> = BTreeMap::new();
let mut image_rows: Vec<ImageRow> = vec![];
@@ -243,7 +253,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
NodeKind::Group { clip: true, .. } => Some("clipGroups"),
NodeKind::Group { .. } => Some("groups"),
NodeKind::Path { guide: true, .. } => Some("guides"),
NodeKind::Path { .. } => Some("paths"),
NodeKind::Path { path, .. } => {
let l = path.length();
if l.is_finite() {
path_length += l;
}
Some("paths")
}
NodeKind::Compound { .. } => Some("compoundPaths"),
NodeKind::Text(t) => {
for run in &t.runs {
@@ -269,6 +285,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
NodeKind::Mesh(_) => Some("meshes"),
NodeKind::Repeat(_) => Some("repeats"),
NodeKind::PlacedDocument(_) => Some("placedDocuments"),
NodeKind::Intertwine { .. } => Some("intertwines"),
};
if let Some(k) = k {
*counts.entry(k).or_default() += 1;
@@ -308,6 +325,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = json!({
"document": document_summary(d),
"objects": counts,
"pathLength": path_length,
"fonts": font_names,
"images": images,
"symbols": symbols,
@@ -354,7 +372,8 @@ fn snap_xf(b: Rect, half: bool) -> Affine {
}
fn pixel_perfect(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = if p.get("ids").is_some() { targets(s, p)? } else { super::edit::selected_roots(s)? };
let ids: Vec<NodeId> =
if p.get("ids").is_some() { super::edit::outermost(&s.doc()?.doc, targets(s, p)?) } else { super::edit::selected_roots(s)? };
let n = ids.len();
s.edit("Make Pixel Perfect", |d, _| {
for id in &ids {
@@ -405,6 +424,30 @@ mod tests {
assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1);
}
/// Document Info › Objects gives the paths' total length in the document's units (#1958): an
/// open line, a closed rectangle's perimeter and a compound path's pieces count, a guide doesn't.
#[test]
fn info_gives_the_paths_total_length() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400, "units": "Millimeters"})).unwrap();
let line = s.execute("shape.line", &json!({"x1": 0, "y1": 0, "x2": 72, "y2": 0})).unwrap()["id"].clone();
let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 10, "height": 20})).unwrap()["id"].clone();
let length = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap()["pathLength"].as_f64().unwrap();
assert!((length(&mut s, json!({})) - 132.0).abs() < 1e-6);
s.execute("select.set", &json!({"ids": [line]})).unwrap();
assert!((length(&mut s, json!({"selectionOnly": true})) - 72.0).abs() < 1e-6);
let rows = s.execute("document.info", &json!({"selectionOnly": true, "category": "objects"})).unwrap()["sections"][0]["rows"].clone();
assert_eq!(rows, json!([["Paths", "1"], ["Path Length", "25.4 mm"]]));
// A compound path's pieces are paths: their lengths count.
let other = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 5, "height": 5})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [rect, other]})).unwrap();
s.execute("object.compoundPath.make", &json!({})).unwrap();
assert!((length(&mut s, json!({"selectionOnly": true})) - 80.0).abs() < 1e-6);
// Nothing with no paths, and no length row.
s.execute("select.none", &json!({})).unwrap();
assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0);
}
#[test]
fn embedding_permissions_read_from_fs_type() {
assert_eq!(embedding_label(0), "embedding allowed");
+135 -12
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
),
@@ -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 }))
@@ -659,6 +771,17 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("guide.move", "no guide selected (or give an index)"));
}
let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false));
if !s.doc()?.selection.is_empty() {
let ids = super::edit::selected_roots(s)?;
return super::object::apply_transform(
s,
if copy { "Copy Guide" } else { "Move Guide" },
ids,
vectorcraft_geom::Affine::translate((dx, dy)),
p,
true,
);
}
s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| {
let mut copies = vec![];
for &i in &moving {
+78 -23
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};
use super::create::{add_look, insert_look};
use super::newart::NewArt;
use super::pathops::{Beside, remove_each};
use super::*;
use crate::EngineError;
@@ -24,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
has_doc,
freehand
),
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Curvature",
[],
None,
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments; insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
has_doc,
curvature_edit
),
@@ -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
),
@@ -69,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Anchor Point",
[],
None,
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored)",
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
has_doc,
convert_anchor
),
@@ -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
),
@@ -544,6 +546,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
let keep_selected = bool_or(p, "keepSelected", true);
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
let at_start = str_param(ext, "end") == Some("start");
@@ -605,7 +608,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
{
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
}
sel.set([id]);
if keep_selected {
sel.set([id]);
} else {
sel.remove(id);
}
Ok(())
})?;
return Ok(json!({ "id": id.0 }));
@@ -622,7 +629,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(st) = look.appearance.stroke_mut() {
st.pressure = pressure;
}
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected)
}
// ---------- Curvature ----------
@@ -643,7 +650,7 @@ fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
if pts.is_empty() {
return Err(bad(C, "need at least one point"));
}
let sp = vectorcraft_tools::draw2::catmull_rom(&pts, bool_or(p, "closed", false));
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
if let Some(id) = id_param(p, "id") {
s.edit("Curvature", |d, sel| {
*path_mut(d, id)? = PathData::single(sp);
@@ -688,6 +695,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
None => EndCurve::Keep,
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
};
let corner = bool_or(p, "corner", false);
let op = match str_param(p, "op") {
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
@@ -712,7 +720,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
ai
}
CurvatureOp::Extend(q) => {
curvature_extend(sp, start, q, how);
curvature_extend(sp, start, q, how, corner);
if start { 0 } else { sp.anchors.len() - 1 }
}
CurvatureOp::Close => {
@@ -766,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)
@@ -774,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.
@@ -819,14 +860,19 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Convert Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if smooth && target.is_none() {
if ai >= sp.anchors.len() {
return Err(EngineError::Other("no such anchor".into()));
}
// Without a handle position the anchor curves as the Curvature tool curves a point; one
// with no direction to take stays as it is.
vectorcraft_tools::draw2::curvature_smooth(sp, ai);
return Ok(());
}
let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
match target {
_ if !smooth => a.retract(),
Some(t) if t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
Some(_) => a.retract(),
None => {
sp.smooth_anchor(ai);
}
Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
_ => a.retract(),
}
Ok(())
})?;
@@ -1301,11 +1347,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;
@@ -1316,20 +1365,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 }))
+36 -9
View File
@@ -137,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate",
["Edit"],
None,
"{dx?, dy?} duplicate the selection in place (offset optional)",
"{ids?: [id…] (selected first; default: the selection), dx?, dy?} duplicate the selection in place (offset optional)",
has_selection,
duplicate
),
@@ -324,9 +324,22 @@ 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…): in
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
/// without the ring. 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), st.isolation))
}
/// 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, None)
}
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
let paths = doc.index_paths(ids.iter().copied());
// A compound-path member acts as its compound (compounds are one object).
@@ -336,23 +349,37 @@ 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 { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
_ => (id, path.cloned()),
}
})
.collect();
sel.dedup_by_key(|(id, _)| *id);
let listed: std::collections::HashSet<NodeId> = sel.iter().map(|(id, _)| *id).collect();
sel.iter()
.filter(|(_, path)| {
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
})
without_inner(doc, sel)
.filter(|(_, path)| path.as_deref().and_then(|p| doc.node_at(p)).is_some_and(|n| !n.is_layer()))
.map(|(id, _)| *id)
.map(|(id, _)| id)
.collect()
}
/// `ids` in the order given, less those whose ancestor is also listed: an object listed with a
/// group that holds it is dropped, as [`roots_of`] drops it from a selection. Nothing else is
/// dropped or replaced: unlike `roots_of`, it keeps layers, ids that aren't in the document and
/// compound-path members.
pub(crate) fn outermost(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
let paths = doc.index_paths(ids.iter().copied());
let placed = ids.into_iter().map(|id| (id, paths.get(&id).cloned())).collect();
without_inner(doc, placed).map(|(id, _)| id).collect()
}
/// The entries of `placed` (ids with their index paths) whose id has no ancestor among its ids.
fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> impl Iterator<Item = (NodeId, Option<Vec<usize>>)> + '_ {
let listed: std::collections::HashSet<NodeId> = placed.iter().map(|(id, _)| *id).collect();
placed.into_iter().filter(move |(_, path)| {
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
})
}
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
// The Artboard tool copies its artboard (and the art on it).
if s.tool_id() == "artboard" {
+210 -4
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
has_doc,
set_params
),
@@ -142,6 +142,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
if id == effects::CROP_MARKS && params.get("style").is_none() {
params["style"] = json!(s.crop_mark_style().id());
}
if id == GLASS {
load_texture(&mut params, C)?;
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
@@ -297,16 +300,30 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
let mut params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
let warp_id = p
.get("warpStyle")
.map(|v| -> Result<String> {
let style = v.as_str().filter(|style| effects::WarpStyle::from_id(style).is_some()).ok_or_else(|| bad(C, "unknown Warp style"))?;
Ok(format!("warp.{style}"))
})
.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) {
load_texture(&mut params, C)?;
}
for id in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
let target = item.effects_item(&n.appearance, C)?;
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
}
if let Some(e) = effect
&& effects::is_three_d(&e.id)
&& visible != Some(false)
{
@@ -323,6 +340,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
}
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
let Some(e) = fx.get_mut(index) else { return false };
if let Some(id) = &warp_id {
e.id.clone_from(id);
}
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, &params), &mut e.params) {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
@@ -342,6 +362,31 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids) }))
}
// ---------- Load Texture ----------
/// Effect › Distort › Glass.
const GLASS: &str = "distort.glass";
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
/// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless
/// `params` give a `texture`; a string must be a texture as Glass stores it.
fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
let pick = params.get("texture").is_none();
let Some(v) = params.get_mut(effects::pixel::TEXTURE_IMAGE) else { return Ok(()) };
match v {
Value::String(s) if effects::pixel::texture(s).is_some() => {}
Value::Object(_) => {
let bytes = fileio::source(v, cmd)?.bytes;
*v = json!(fileio::texture_image(&bytes).map_err(|e| bad(cmd, format!("textureImage: {e}")))?);
if pick {
params["texture"] = json!(effects::pixel::LOADED);
}
}
_ => return Err(bad(cmd, "textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it")),
}
Ok(())
}
// ---------- Expand Appearance ----------
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
@@ -407,7 +452,7 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(mut n) = d.node(id).cloned() else { return };
if !expandable(&n) {
return;
@@ -647,10 +692,98 @@ mod tests {
assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false}));
}
/// Scripts from before Scribble's separate variations give one `variation`, which is stored as the
/// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°.
#[test]
fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() {
let (mut s, id) = session_with_rect();
let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}});
s.execute("effect.apply", &p).unwrap();
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s));
assert_eq!((&params(&s)["overlapVariation"], &params(&s)["spacingVariation"]), (&json!(2), &json!(2)));
s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap();
assert_eq!(params(&s)["angle"].as_f64(), Some(160.0));
// A file of then stores its variation; setting one of the two keeps it for the other.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3});
s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap();
assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1}));
}
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
effects::revolve_art(&node(s, id)).unwrap()
}
#[test]
fn smooth_revolve_commands_validate_save_expand_and_undo() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap();
let initial = node(&s, id);
let source = initial.path_data().unwrap().clone();
assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap());
let mut gradients = 0;
effects_for_test(&s, id).walk(&mut |face| {
gradients += face.appearance.items.iter().filter(|item| {
matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_)))
}).count();
});
assert!(gradients > 0, "commands use smooth vector shading by default");
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap();
let smooth = node(&s, id);
assert_eq!(smooth.path_data(), Some(&source));
assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap());
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id)));
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
assert!(!effects::has_revolve(&node(&s, id)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, id), smooth);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, id), initial);
}
#[test]
fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let source = node(&s, id).path_data().unwrap().clone();
s.execute(
"effect.apply",
&json!({"effect":"threeD.revolve","ids":[id.0],"item":null,
"params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}),
)
.unwrap();
let uncapped = node(&s, id);
assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false));
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
let open_count = count(&effects_for_test(&s, id));
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap();
let capped = node(&s, id);
let art = effects_for_test(&s, id);
assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap");
assert_eq!(capped.path_data(), Some(&source));
assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err());
assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document");
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true));
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone()));
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF"));
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
assert_eq!(count(&node(&s, id)), count(&art));
assert!(!effects::has_revolve(&node(&s, id)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, id), capped);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, id), uncapped);
}
#[test]
fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() {
let mut s = Session::new();
@@ -695,6 +828,56 @@ mod tests {
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
}
#[test]
fn warp_options_style_change_preserves_slot_and_visibility() {
let (mut s, id) = session_with_rect();
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap();
}
s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}}));
assert!(result.is_ok(), "an applied warp can change style in place: {result:?}");
let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap();
let n = node(&s, id);
let new = n.appearance.effects_at(Some(1)).unwrap();
assert_eq!(new.len(), 3);
assert_eq!(new[0], old[0]);
assert_eq!(new[2], old[2]);
assert_eq!(new[1].id, "warp.flag");
assert_eq!(new[1].params["bend"], 25.0);
assert!(!new[1].visible);
assert!(n.appearance.effects.is_empty());
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
assert!(!s.doc().unwrap().is_dirty());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
}
#[test]
fn warp_options_non_warp_target_rejection_is_atomic() {
let (mut s, first) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap();
s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo = s.doc().unwrap().history.undo.len();
assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err());
let state = s.doc().unwrap();
assert_eq!(state.doc, before);
assert_eq!(state.selection, selection);
assert_eq!(state.history.undo.len(), undo);
assert!(!state.is_dirty());
}
#[test]
fn list_remove_and_set_params() {
let (mut s, id) = session_with_rect();
@@ -770,6 +953,29 @@ mod tests {
assert_eq!(node(&s, a).appearance.effects.len(), 1);
}
#[test]
fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() {
let (mut s, a) = session_with_rect();
let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap());
let glyphs = effects::outline_text(&node(&s, t)).unwrap();
let b = glyphs.geometric_bounds().unwrap();
assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}");
let area = |n: &vectorcraft_doc::Node| effects::member_shapes(n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::<f64>();
let glyph_area = area(&glyphs);
s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap();
// Live: the renderer leaves the glyph's place in the square empty.
let doc = s.doc().unwrap().doc.clone();
let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count();
assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}");
// Expanded: the group holds the square minus the glyph.
s.execute("effect.expandAppearance", &json!({})).unwrap();
let left = area(&node(&s, g));
assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}");
}
#[test]
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
let (mut s, a) = session_with_rect();
+26 -1
View File
@@ -1,6 +1,7 @@
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
//! SVG (with `metadata`), PDF and PNG writers carry it.
//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
//! it gives ([`read_pdf`]).
use serde_json::{Map, Value, json};
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
@@ -62,6 +63,30 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
Ok(info_json(&s.doc()?.doc))
}
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
/// field that `file.info` would take (a value it refuses is left out), and the created and
/// modified dates.
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
let fields = [
("author", f.author.map(Value::from)),
("description", f.description.map(Value::from)),
("keywords", f.keywords.map(Value::from)),
("copyrightStatus", status.map(|s| Value::from(s.id()))),
("copyrightNotice", f.copyright_notice.map(Value::from)),
("copyrightUrl", f.copyright_url.map(Value::from)),
];
// `apply` changes the field only when it takes the value.
let mut title = String::new();
for (k, v) in fields {
if let Some(v) = v {
let _ = apply(&mut title, m, k, &v);
}
}
m.created = f.created.or(m.created);
m.modified = f.modified.or(m.modified);
}
fn apply(title: &mut String, m: &mut DocMetadata, k: &str, v: &Value) -> Result<()> {
match k {
"title" => {
@@ -0,0 +1,451 @@
//! `action.batch`: play an action's commands on every file in a folder, or on every data set
//! of the open document (the Actions panel menu's Batch…).
//!
//! Public options follow the Batch dialog: source Folder or Data Sets; destination None,
//! 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 super::super::*;
#[cfg(target_arch = "wasm32")]
pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result<Value> {
Err(bad("action.batch", "no file system here"))
}
/// Caps on a batch (folders and MCP payloads are untrusted).
#[cfg(not(target_arch = "wasm32"))]
const MAX_BATCH_FILES: usize = 500;
#[cfg(not(target_arch = "wasm32"))]
const MAX_BATCH_DEPTH: usize = 8;
/// Shared Batch options for one run.
#[cfg(not(target_arch = "wasm32"))]
struct Run<'a> {
label: &'a str,
steps: &'a [Value],
stop_on_error: bool,
override_save: bool,
override_export: bool,
destination: &'a str,
dest_folder: Option<&'a str>,
}
/// Play `commands` on each source file or data set.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
use super::{absolute_path, may_write};
const C: &str = "action.batch";
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad(C, "missing `commands`"))?;
if cmds.is_empty() {
return Err(bad(C, "`commands` must not be empty"));
}
if cmds.len() > 256 {
return Err(bad(C, "more than 256 commands"));
}
for (i, c) in cmds.iter().enumerate() {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if id.is_empty() {
return Err(bad(C, format!("commands[{i}]: missing `command`")));
}
if id == "command.batch" || id == "action.batch" {
return Err(bad(C, "batches cannot nest"));
}
if find_command(id).is_none() {
return Err(bad(C, format!("commands[{i}]: unknown command `{id}`")));
}
}
let source = str_param(p, "source").unwrap_or("folder");
let destination = str_param(p, "destination").unwrap_or("none");
if !matches!(destination, "none" | "saveAndClose" | "folder") {
return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`"));
}
let dest_folder = str_param(p, "destinationFolder").map(absolute_path);
if destination == "folder" {
let Some(ref folder) = dest_folder else {
return Err(bad(C, "`destinationFolder` is required when destination is `folder`"));
};
may_write(folder)?;
std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?;
}
let label = str_param(p, "label").unwrap_or("Batch").to_string();
let override_open = bool_or(p, "overrideOpen", true);
let run = Run {
label: &label,
steps: cmds,
stop_on_error: bool_or(p, "stopOnError", true),
override_save: bool_or(p, "overrideSave", true),
override_export: bool_or(p, "overrideExport", true),
destination,
dest_folder: dest_folder.as_deref(),
};
match source {
"folder" => batch_folder(s, C, p, override_open, &run),
"datasets" => batch_datasets(s, C, &run),
_ => Err(bad(C, "`source` must be `folder` or `datasets`")),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
use super::{absolute_path, may_read};
let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?;
may_read(&folder)?;
let include_subdirs = bool_or(p, "includeSubdirs", false);
let files = collect_files(&folder, include_subdirs, 0)?;
if files.len() > MAX_BATCH_FILES {
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files")));
}
if files.is_empty() {
return Err(bad(cmd, "no readable files in the folder"));
}
let steps = filter_steps(run.steps, override_open, false);
let run = Run { steps: &steps, ..*run };
let mut processed = Vec::new();
let mut failed = 0usize;
for path in &files {
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = super::extension(path);
match process_one_file(s, &run, path, stem, &ext) {
Ok(row) => processed.push(row),
Err(e) => {
failed += 1;
processed.push(json!({"path": path, "error": e.to_string()}));
if run.stop_on_error {
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
}
}
}
}
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
}
#[cfg(not(target_arch = "wasm32"))]
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
let st = s.doc()?;
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
if names.is_empty() {
return Err(bad(cmd, "no data sets"));
}
if names.len() > MAX_BATCH_FILES {
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();
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) {
Ok(row) => processed.push(row),
Err(e) => {
failed += 1;
processed.push(json!({"dataset": name, "error": e.to_string()}));
if run.stop_on_error {
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
}
}
}
}
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
}
/// Drop Open (and optionally Close) steps when the batch supplies the file itself.
#[cfg(not(target_arch = "wasm32"))]
fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value> {
cmds.iter()
.filter(|c| {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if drop_open && matches!(id, "document.open" | "file.open") {
return false;
}
if drop_close && matches!(id, "file.close" | "document.close") {
return false;
}
true
})
.cloned()
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result<Value> {
s.execute("document.open", &json!({"path": path}))?;
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext);
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
// Folder sources open a file per pass: close after Save and Close / Folder.
finish_destination(s, run, stem, ext, path, true)?;
Ok(json!({"path": path, "result": batch}))
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_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 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)?;
Ok(json!({"dataset": name, "result": batch}))
}
#[cfg(not(target_arch = "wasm32"))]
fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, source_path: &str, close: bool) -> Result<()> {
match run.destination {
"none" => Ok(()),
"saveAndClose" => {
if !source_path.is_empty() {
s.execute("document.save", &json!({"path": source_path}))?;
} else {
s.execute("document.save", &json!({}))?;
}
if close {
s.execute("file.close", &json!({}))?;
}
Ok(())
}
"folder" => {
let folder = run.dest_folder.ok_or_else(|| bad("action.batch", "missing destination folder"))?;
let out_ext = if ext.is_empty() { "vectorcraft" } else { ext };
let out = std::path::Path::new(folder).join(format!("{stem}.{out_ext}"));
let out = out.to_string_lossy().into_owned();
// When `close` is false (data sets), write a copy so the template keeps its path.
write_result(s, &out, out_ext, !close)?;
if close {
s.execute("file.close", &json!({}))?;
}
Ok(())
}
_ => Ok(()),
}
}
/// Save when the extension is a Save format; otherwise export (PNG, JPEG…).
/// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches).
#[cfg(not(target_arch = "wasm32"))]
fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result<()> {
use super::SAVE_FORMATS;
let id = if ext == "drawcraft" { "vectorcraft" } else { ext };
if SAVE_FORMATS.contains(&id) {
let cmd = if as_copy { "file.saveCopy" } else { "document.save" };
s.execute(cmd, &json!({"path": path}))?;
} else {
s.execute("document.export", &json!({"path": path, "format": id}))?;
}
Ok(())
}
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
#[cfg(not(target_arch = "wasm32"))]
fn rewrite_paths(
steps: &[Value],
override_save: bool,
override_export: bool,
dest_folder: Option<&str>,
stem: &str,
default_ext: &str,
) -> Vec<Value> {
let Some(folder) = dest_folder else {
return steps.to_vec();
};
steps
.iter()
.map(|c| {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
let mut c = c.clone();
let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
if !rewrite {
return c;
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = params.as_object().cloned().unwrap_or_default();
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
let ext = if ext.is_empty() { default_ext } else { ext };
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
params.insert("path".into(), json!(file.to_string_lossy()));
if let Some(o) = c.as_object_mut() {
o.insert("params".into(), Value::Object(params));
}
c
})
.collect()
}
/// Readable files under `folder` (and optionally its subfolders), capped.
#[cfg(not(target_arch = "wasm32"))]
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
use std::path::PathBuf;
use super::list_dir;
if depth > MAX_BATCH_DEPTH {
return Err(bad("action.batch", format!("folders deeper than {MAX_BATCH_DEPTH} levels")));
}
let (entries, _) = list_dir(folder, MAX_BATCH_FILES.saturating_mul(2))?;
let mut files = Vec::new();
let mut subdirs = Vec::new();
for e in entries {
let name = e.get("name").and_then(Value::as_str).unwrap_or("");
if name.is_empty() || name.starts_with('.') {
continue;
}
let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
let path = PathBuf::from(folder).join(name);
let path = path.to_string_lossy().into_owned();
match kind {
"file" if is_openable(&path) => files.push(path),
"folder" if include_subdirs => subdirs.push(path),
_ => {}
}
}
files.sort();
for sub in subdirs {
let mut nested = collect_files(&sub, true, depth + 1)?;
files.append(&mut nested);
if files.len() > MAX_BATCH_FILES {
break;
}
}
Ok(files)
}
#[cfg(not(target_arch = "wasm32"))]
fn is_openable(path: &str) -> bool {
use super::{FORMATS, OPEN_EXTS, extension};
let ext = extension(path);
if ext.is_empty() {
return false;
}
OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext))
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
use serde_json::json;
fn tmp(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-action-batch-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn write_svg(dir: &std::path::Path, name: &str, fill: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(
&p,
format!(
r#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" width="100" height="80">
<rect x="10" y="10" width="40" height="30" fill="{fill}"/></svg>"#
),
)
.unwrap();
p
}
#[test]
fn batch_folder_runs_action_and_saves_to_destination() {
let src = tmp("src");
let dest = tmp("dest");
write_svg(&src, "a.svg", "#f00");
write_svg(&src, "b.svg", "#0f0");
let mut s = Session::new();
// Need a document so command.batch's has_doc passes after the first open leaves one.
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "object.unlockAll", "params": {}}],
"label": "Unlock"
}),
)
.unwrap();
assert_eq!(r["count"], 2);
assert_eq!(r["failed"], 0);
assert!(dest.join("a.svg").is_file(), "a.svg written");
assert!(dest.join("b.svg").is_file(), "b.svg written");
}
#[test]
fn batch_skips_open_steps_when_override_open() {
let src = tmp("open");
let dest = tmp("open-out");
write_svg(&src, "only.svg", "#00f");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"overrideOpen": true,
"commands": [
{"command": "document.open", "params": {"path": "/no/such/file.svg"}},
{"command": "object.unlockAll", "params": {}}
]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert!(dest.join("only.svg").is_file());
}
#[test]
fn batch_datasets_applies_each_row() {
let dest = tmp("sets");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap();
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap();
s.execute("dataset.capture", &json!({})).unwrap();
s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap();
s.execute("dataset.capture", &json!({})).unwrap();
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "object.unlockAll", "params": {}}],
"label": "Per set"
}),
)
.unwrap();
assert_eq!(r["count"], 2, "{r}");
assert_eq!(r["failed"], 0, "{r}");
let written: Vec<_> = std::fs::read_dir(&dest).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect();
assert_eq!(written.len(), 2, "{written:?}");
}
#[test]
fn batch_refuses_nesting_and_empty_commands() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
assert!(s.execute("action.batch", &json!({"commands": []})).is_err());
assert!(
s.execute(
"action.batch",
&json!({
"folder": ".",
"commands": [{"command": "command.batch", "params": {"commands": []}}]
})
)
.is_err()
);
}
}
+10 -4
View File
@@ -55,7 +55,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
/// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
/// document's last artboard, the last one.
/// document's last artboard, the last one. A step that resizes the active artboard
/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`.
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
let mut results = Vec::with_capacity(cmds.len());
for c in cmds {
@@ -67,13 +68,18 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
s.begin_interaction(label)?;
}
let mut params = c.get("params").cloned().unwrap_or(json!({}));
let key = if crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str()) {
Some("artboard")
} else {
crate::FIT_ACTIVE_ARTBOARD.contains(&id.as_str()).then_some("index")
};
if let Some(a) = artboard
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
&& params.get("artboard").is_none()
&& let Some(key) = key
&& params.get(key).is_none()
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
&& let Some(o) = params.as_object_mut()
{
o.insert("artboard".into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
o.insert(key.into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
}
let (v, noted) =
s.execute_step(&id, &params).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
+44 -17
View File
@@ -94,13 +94,15 @@ 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 and sublayers, 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` ([`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.
/// 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.
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
fn kept(n: &Arc<Node>, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
@@ -109,11 +111,21 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
return Some(Arc::new(layer));
}
// A group that doesn't clip keeps only its members over the artboard, so art gathered in one
// group across several artboards splits by artboard. Not with effects of its own: a shadow
// can reach in from a member outside.
if matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.appearance.effects.is_empty() {
let mut group = Node::clone(n);
let children = group.children_mut()?;
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
return (!children.is_empty()).then(|| Arc::new(group));
}
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();
@@ -147,28 +159,42 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Co
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
return Ok(Cow::Owned(selection(s, cmd)?.0));
}
export_source(s.doc()?, p)
let st = s.doc()?;
let doc = export_source(st, p)?;
Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned))
}
/// 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
/// it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; symbol
/// instances are measured by their art), `None` when they have none.
pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option<vectorcraft_geom::Rect> {
let symbols = doc.symbol_boxes();
ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b))
}
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
/// named `name`: their visual bounds, symbol instances measured by their art (`None` when they
/// have none).
/// named `name`: their [`export_crop`] (`None` when they have none).
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
let bounds = export_crop(doc, ids)?;
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
let symbols = doc.symbol_boxes();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))?;
let mut d = single_artboard(doc, bounds, name);
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
@@ -178,8 +204,9 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do
Some((d, bounds))
}
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
/// template layers are guides, not artwork, and are left out.
/// File → Export Selection: the selected objects alone, cropped to the bounds the renderer
/// measures for what they draw, their effects included ([`isolated`]). Objects on template layers
/// are guides, not artwork, and are left out.
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportSelection";
if let Some(path) = str_param(p, "path") {
+310
View File
@@ -0,0 +1,310 @@
//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7 (the PostScript-form `.ai`
//! that `vectorcraft_eps::export_legacy_ai` writes), which keep the layers, groups, compound
//! paths and clipping groups that a PDF-compatible `.ai` loses in apps reading only its PDF part
//! (#868).
//!
//! The format has one artboard: each chosen artboard (default all) is a file of its own, holding
//! the art that touches it, named `{stem}-{artboard}.ai` beside the path when there are several.
//! Appearances the format can't hold (several fills or strokes, effects, brushes) are expanded into
//! objects. Transparency isn't flattened: flattening merges the art that overlaps transparent
//! objects, across layers, into one flattened result, which empties the layers under them, and
//! editable layers are what this format is saved for. Transparent objects are written opaque (a
//! warning says so).
//!
//! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
//! the art under it stays as it was. With `omit` they are left out.
//!
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
//! left out, with `visible` they show where they are. Type is written as text the format's reader
//! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that
//! 8-bit text can't hold always is).
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath};
use super::super::*;
use super::{ArtboardPick, Encoded, Format};
/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error.
pub(super) fn wanted(cmd: &str, p: &Value) -> Result<bool> {
match p.get("aiVersion") {
None | Some(Value::Null) => Ok(false),
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false),
Some(Value::String(v)) if v.trim() == "7" => Ok(true),
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true),
Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
}
}
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SeeThrough {
Flatten,
Omit,
}
/// Where a legacy `.ai`'s hidden objects go (`hiddenObjects`): the format hides only layers.
fn hidden_objects(cmd: &str, p: &Value) -> Result<vectorcraft_eps::HiddenObjects> {
use vectorcraft_eps::HiddenObjects;
match p.get("hiddenObjects") {
None | Some(Value::Null) => Ok(HiddenObjects::Layer),
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("layer") => Ok(HiddenObjects::Layer),
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(HiddenObjects::Omit),
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("visible") => Ok(HiddenObjects::Visible),
Some(v) => Err(bad(cmd, format!("hiddenObjects {v}: \"layer\" (the default), \"omit\" or \"visible\""))),
}
}
/// Type as outlines (`outlineText`), not text objects.
fn outline_text(cmd: &str, p: &Value) -> Result<bool> {
match p.get("outlineText") {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(v) => Err(bad(cmd, format!("outlineText {v}: true or false"))),
}
}
fn see_through(cmd: &str, p: &Value) -> Result<SeeThrough> {
match p.get("transparentImages") {
None | Some(Value::Null) => Ok(SeeThrough::Flatten),
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("flatten") => Ok(SeeThrough::Flatten),
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(SeeThrough::Omit),
Some(v) => Err(bad(cmd, format!("transparentImages {v}: \"flatten\" (the default) or \"omit\""))),
}
}
/// What the format loses against a VectorCraft document, reported with every save.
const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, type, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable";
const FLATTENED: &str =
"transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images";
const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out";
const SEPARATE: &str = "legacy .ai files have one artboard: each artboard is saved to a file of its own";
/// Encode `doc` as legacy `.ai` files of version 7: one per chosen artboard (`artboard`, `artboards` or
/// `range`; default all).
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
let n = doc.artboards.len();
if n == 0 {
return Err(bad(cmd, "the document has no artboard"));
}
let mode = see_through(cmd, p)?;
let (hidden, outline_text) = (hidden_objects(cmd, p)?, outline_text(cmd, p)?);
let picked: ArtboardPick = super::encode::options(f, p)?;
let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect());
let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() };
if boards.len() > 1 {
enc.warnings.push(SEPARATE.to_string());
}
let created = vectorcraft_doc::metadata::now_unix();
for b in boards {
let one = super::save::artboard_doc(doc, b);
let (full, placed) = crate::cmd::place::document::full_documents(&one);
let mut d = full.into_owned();
expand_appearances(&mut d);
let images = see_through_images(&d, mode);
if !images.is_empty() {
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
if mode == SeeThrough::Flatten {
flatten_images(&mut d, &images)?;
} else {
for (_, id) in images {
let _ = d.remove(id); // Inside an image left out before: gone already.
}
}
}
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
for w in placed.iter().chain(&out.warnings) {
note(&mut enc.warnings, w);
}
enc.files.push((Some(b), out.bytes));
}
Ok(enc)
}
/// Expand Appearance of every layer's art whose appearance the format can't hold.
fn expand_appearances(d: &mut Document) {
let brushes = vectorcraft_brush::library(d);
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
let mut out = vec![];
for id in layers {
super::super::effectcmd::expand_node(d, id, &brushes, &mut out);
}
}
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only
/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over
/// white); all are left out with `omit`.
fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
let keys = super::super::flatten::see_through_images(d);
let mut out = vec![];
let mut at = 0;
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind
&& (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal)
{
out.push((at, n.id));
}
at += 1;
});
if mode == SeeThrough::Flatten {
out.retain(|(_, id)| d.is_visible(*id));
}
out
}
/// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows
/// where it is, clipped to where it changes that; one that changes nothing is left out. Each is
/// rendered from `d` as it was, so images over one another flatten as they look.
fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> {
let src = d.clone();
for &(at, id) in images {
let Some((parent, idx, _)) = d.position(id) else { continue };
let flat = flattened(&src, at, id)?;
d.remove(id)?;
if let Some(flat) = flat {
let png = flat.png;
let key = super::super::menucmds::unique_key(d, "flattened");
d.images.insert(key.clone(), ImageBlob::new("image/png", png));
let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() };
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image);
group.name = src.node(id).and_then(|n| n.name.clone());
d.insert(parent, idx, group)?;
}
}
Ok(())
}
/// An image made by [`flattened`]: PNG pixels, their place, and the outline they are clipped to.
struct Flat {
png: Vec<u8>,
width: u32,
height: u32,
xf: Affine,
clip: PathData,
}
/// Highest and lowest resolution of a flattened image (pixels per inch); it takes the image's own
/// in between.
const MAX_PPI: f64 = 600.0;
const MIN_PPI: f64 = 72.0;
/// A rendered pixel channel changes when it changes by more than this.
const CHANGED: u8 = 2;
/// The clip outline follows cells of about this many points.
const CELL: f64 = 2.0;
/// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and
/// including it, over white, cropped and clipped to the cells where drawing it changes the
/// result. `None` when it changes nothing.
fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
let (Some(n), Some(bounds)) = (src.node(id), src.bounds_of(&[id], true)) else { return Ok(None) };
let NodeKind::Image(im) = &n.kind else { return Ok(None) };
// The image's own resolution (points per pixel from its placement).
let pt = im.xf.determinant().abs().sqrt();
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
let area = (bounds.width() * bounds.height()).max(1.0);
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
if !(scale.is_finite() && scale > 0.0) {
return Ok(None);
}
let region = Rect::new(
bounds.x0,
bounds.y0,
bounds.x0 + (bounds.width() * scale).ceil().max(1.0) / scale,
bounds.y0 + (bounds.height() * scale).ceil().max(1.0) / scale,
);
let mut with = src.clone();
let mut i = 0;
with.layers = src.layers.iter().filter_map(|l| up_to(l, at, &mut i)).map(Arc::new).collect();
let mut without = with.clone();
without.remove(id)?;
let mut r = vectorcraft_render::Renderer::new();
let shown = r.render_region(&with, region, scale, true);
let under = r.render_region(&without, region, scale, true);
let (w, h) = (shown.width as usize, shown.height as usize);
if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() {
return Ok(None);
}
// The cells where it changes anything.
let c = (CELL * scale).round().max(1.0) as usize;
let (cw, ch) = (w.div_ceil(c), h.div_ceil(c));
let mut cells = vec![false; cw * ch];
for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() {
if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) {
let (x, y) = (k % w, k / w);
if let Some(cell) = cells.get_mut(y / c * cw + x / c) {
*cell = true;
}
}
}
let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw));
let (Some(x0), Some(x1), Some(y0), Some(y1)) =
(marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max())
else {
return Ok(None);
};
// Crop to them.
let (px0, py0) = (x0 * c, y0 * c);
let (px1, py1) = (((x1 + 1) * c).min(w), ((y1 + 1) * c).min(h));
let mut pixels = Vec::with_capacity((px1 - px0) * (py1 - py0) * 4);
for y in py0..py1 {
pixels.extend_from_slice(shown.pixels.get((y * w + px0) * 4..(y * w + px1) * 4).unwrap_or_default());
}
let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels };
let png = crop.to_png().map_err(EngineError::Other)?;
let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale);
let mut clip = PathData::new(vec![]);
for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) {
for lp in std::iter::once(&comp.outer).chain(&comp.holes) {
let pts: Vec<(i32, i32)> = corners(&lp.pts);
let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect();
clip.subpaths.push(SubPath::new(anchors, true));
}
}
let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale);
Ok(Some(Flat { png, width: crop.width, height: crop.height, xf, clip }))
}
/// `n` without the nodes after the `last`th in paint order (`i` counts them, as
/// [`Document::walk`] does); `None` when `n` itself comes after it.
fn up_to(n: &Node, last: usize, i: &mut usize) -> Option<Node> {
if *i > last {
return None;
}
*i += 1;
let mut n = n.clone();
if let Some(ch) = n.children_mut() {
let kept: Vec<Arc<Node>> = ch.iter().filter_map(|c| up_to(c, last, i)).map(Arc::new).collect();
*ch = kept;
}
Some(n)
}
/// A loop's corners: its points without those in the middle of a straight run.
fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> {
let n = pts.len();
let get = |k: usize| pts.get(k % n.max(1)).copied();
(0..n)
.filter_map(|k| {
let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?);
let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1);
(!straight).then_some(b)
})
.collect()
}
fn note(warnings: &mut Vec<String>, w: &str) {
if !warnings.iter().any(|x| x == w) {
warnings.push(w.to_string());
}
}
+51 -4
View File
@@ -5,6 +5,7 @@ use std::io::Cursor;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::*;
use super::pdfimport::LoadOptions;
@@ -153,14 +154,15 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
/// A PDF-compatible `.ai`, or a PDF saved with the editing data, read through the editor's own copy
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
/// Anything else as it was.
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
/// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its
/// first page's `UserUnit`).
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>) {
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
return (doc, warnings);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data);
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings);
@@ -228,7 +230,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art).
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings)))
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
}
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
@@ -253,6 +255,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
if matches!(format.id, "pdf" | "ai" | "ait")
&& !restored
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
{
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames.
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
@@ -482,6 +492,43 @@ fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result<RasterImage>
stored(png, "image/png", size, ppi)
}
/// Effect › Distort › Glass › Load Texture…: encoded image `bytes`, in any format [`raster_image`]
/// reads, as the `textureImage` Glass stores ([`LoadedTexture::to_stored`]). The pixels are decoded
/// within [`MAX_RASTER_ALLOC`], counted as RGBA, as the renderer decodes them (#1717); a CMYK image
/// is converted with the active color settings.
pub fn texture_image(bytes: &[u8]) -> Result<String> {
if let Some(note) = too_large_note(bytes) {
return Err(err(note));
}
let RasterImage { blob, width, height, .. } = raster_image(bytes)?;
// The allowance counts the pixels as RGBA: a gray image takes four times its file's samples (#1717).
if u64::from(width) * u64::from(height) * 4 > MAX_RASTER_ALLOC {
return Err(err(format!("an image of {width} × {height} pixels is too large to draw")));
}
let texture = match blob.cmyk() {
Some(inks) => {
let cms = vectorcraft_color::cms::active();
let rgba: Vec<u8> = (inks.data.as_chunks::<4>().0.iter())
.flat_map(|c| {
let rgb = cms.cmyk_to_srgb(c.map(|v| f32::from(v) / 255.0), false);
let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
[r, g, b, 255]
})
.collect();
LoadedTexture::from_rgba(inks.width, inks.height, &rgba)
}
None => {
let mut reader = image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(err)?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
reader.limits(limits);
let img = reader.decode().map_err(err)?.to_rgba8();
LoadedTexture::from_rgba(img.width(), img.height(), img.as_raw())
}
};
texture.and_then(|t| t.to_stored()).ok_or_else(|| err("the image can't be read as a texture"))
}
/// An image's encoded `bytes`, `width` × `height` pixels, ready to embed.
fn stored(bytes: Vec<u8>, mime: &'static str, (width, height): (u32, u32), ppi: Option<(f64, f64)>) -> Result<RasterImage> {
if width == 0 || height == 0 {
+83 -13
View File
@@ -1,7 +1,9 @@
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and
//! `action.batch` (the Actions panel menu's Batch…).
//!
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
//! - `action_batch`: play an action on every file in a folder or every data set.
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
@@ -19,6 +21,7 @@
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
mod action_batch;
mod affinity;
mod batch;
pub mod dxf;
@@ -27,9 +30,11 @@ mod encode;
pub mod eps;
mod export;
pub(crate) mod imagemap;
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;
@@ -48,7 +53,7 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub use export::export_source;
pub(crate) use export::isolated;
pub(crate) use export::{export_crop, isolated};
pub(crate) use load::check_not_lossy_overwrite;
use load::err;
pub(crate) use load::import_svg;
@@ -56,18 +61,22 @@ pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
read_for_open,
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};
pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, export_folder, save_filters, save_format, save_job, save_plan, save_with,
stamp_save_dates, templates_dialog_folder, templates_folder,
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::{
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 use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, 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,
};
@@ -105,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@@ -114,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|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 writes 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: every page) | false (pdf/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…], 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",
has_doc,
export::export
),
@@ -123,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
has_selection,
export::export_selection
),
@@ -132,19 +141,55 @@ 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|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|svg|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",
[],
None,
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
has_doc,
batch::batch
),
cmd!(
"action.batch",
"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}",
always,
action_batch::action_batch
),
cmd!(
query "document.formats",
"File Formats",
@@ -159,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
"PDF Info",
[],
None,
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; an encrypted PDF without its password → {pages: 0, needsPassword: true}",
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
always,
pdfimport::pdf_info
),
@@ -496,7 +541,17 @@ pub const FORMATS: &[Format] = &[
raster: false,
options: native::AI_OPTIONS,
},
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
Format {
id: "ait",
label: "PDF-compatible .ait template",
extensions: &["ait"],
mime: "application/pdf",
read: true,
// Save as Template writes it, with the .ai file's options; exports don't.
write: false,
raster: false,
options: native::AI_OPTIONS,
},
Format {
id: "png",
label: "PNG",
@@ -683,6 +738,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Shortcut sets", super::shortcutset::EXTS)))
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
@@ -710,6 +766,18 @@ pub fn file_stem(name: &str) -> String {
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
}
/// What a `format` export of `doc`, whose file is `path`, writes when it isn't `doc` itself: a
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone();
d.title = file_stem(path);
Some(d)
}
/// The format a file name's extension names.
pub fn format_for_name(name: &str) -> Option<&'static Format> {
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
@@ -1107,6 +1175,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}");
}
}
}
+24
View File
@@ -99,6 +99,30 @@ pub const AI_OPTIONS: &[FormatOption] = &[
default: "true",
description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)",
},
FormatOption {
name: "aiVersion",
ty: "string",
default: "\"pdf\"",
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply)",
},
FormatOption {
name: "transparentImages",
ty: "string",
default: "\"flatten\"",
description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)",
},
FormatOption {
name: "hiddenObjects",
ty: "string",
default: "\"layer\"",
description: "with aiVersion 7, hidden objects, which version 7 can't hide (it hides only layers): layer (the default: each layer's hidden objects in a hidden layer of their own just above it, out of their groups), omit (left out) or visible (shown where they are)",
},
FormatOption {
name: "outlineText",
ty: "boolean",
default: "false",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines",
},
];
#[derive(Default, Deserialize)]
+4 -2
View File
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), form Line truncated
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from a Line truncated
has_doc,
export_pdf
),
@@ -365,7 +365,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
}
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?;
let st = s.doc()?;
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?;
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
}
+53 -18
View File
@@ -22,13 +22,27 @@ use crate::{DocState, Prefs};
/// they never become the document's own file.
pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"];
/// The formats Save as Template writes, the default first: a native template, or a
/// PDF-compatible `.ait` (written as a `.ai` file is).
pub const TEMPLATE_FORMATS: &[&str] = &["template", "ait"];
/// Save-panel filters `(label, [extension])` for `first` (a format id, or a file name whose
/// extension names one): when Save writes that format, one per [`SAVE_FORMATS`] entry with it
/// leading (the panel's default type), else none (an export picks its own). Only the extension a
/// save writes (never the former native name).
pub fn save_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> {
let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] };
let mut v: Vec<&'static Format> = SAVE_FORMATS.iter().filter_map(|id| format(id)).collect();
filters(SAVE_FORMATS, first)
}
/// [`save_filters`] for Save as Template: one per [`TEMPLATE_FORMATS`] entry.
pub fn template_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> {
filters(TEMPLATE_FORMATS, first)
}
/// One filter per format of `ids`, `first` leading; none when `first` names none of them.
fn filters(ids: &[&str], first: &str) -> Vec<(&'static str, &'static [&'static str])> {
let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| ids.contains(&f.id)) else { return vec![] };
let mut v: Vec<&'static Format> = ids.iter().filter_map(|id| format(id)).collect();
v.sort_by_key(|f| f.id != first.id);
v.into_iter().map(|f| (f.label, &f.extensions[..1])).collect()
}
@@ -73,7 +87,7 @@ pub struct SavePlan {
pub format: &'static Format,
/// The format's options (only those its encoder reads).
pub options: Map<String, Value>,
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.vctemplate`.
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.<ext>`.
pub name: String,
/// Suggested folder: the document's own, or the Templates folder for a template.
pub folder: Option<String>,
@@ -194,7 +208,11 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
let st = s.doc()?;
let path = str_param(p, "path").map(str::to_string);
let format = match (mode, str_param(p, "format"), path.as_deref().and_then(format_for_name)) {
(SaveMode::Template, ..) => checked(cmd, "template")?,
// `.ait` when the format or else the path's extension names it; else a native template.
(SaveMode::Template, f, by_name) => match f.map_or(by_name, format).filter(|f| TEMPLATE_FORMATS.contains(&f.id)) {
Some(f) => f,
None => checked(cmd, "template")?,
},
(_, Some(f), _) => checked(cmd, f)?,
(_, None, Some(f)) => checked(cmd, f.id)?,
(_, None, None) => checked(cmd, st.format)?,
@@ -248,13 +266,13 @@ fn reads_option(f: &Format, key: &str) -> bool {
}
/// Formats that carry the whole native document: they lose nothing, and record links relative to
/// where they are written. A `.ai` file is a PDF carrying it.
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it.
fn is_native(f: &Format) -> bool {
matches!(f.id, "vectorcraft" | "template" | "ai")
matches!(f.id, "vectorcraft" | "template" | "ai" | "ait")
}
fn is_pdf(f: &Format) -> bool {
matches!(f.id, "pdf" | "ai")
matches!(f.id, "pdf" | "ai" | "ait")
}
fn is_svg(f: &Format) -> bool {
@@ -335,7 +353,8 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
/// The artboards a native or `.ai` save also writes to files of their own: with
/// `separateArtboards`, those `range` names (default all).
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
if !is_native(f) || !bool_or(p, "separateArtboards", false) {
// A legacy .ai file is a file per artboard already.
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? {
return Ok(vec![]);
}
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
@@ -346,7 +365,7 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<V
/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file):
/// objects whose bounds touch the artboard, in their layers; resources are kept.
fn artboard_doc(doc: &Document, i: usize) -> Document {
pub(super) fn artboard_doc(doc: &Document, i: usize) -> Document {
let mut d = doc.clone();
let Some(a) = doc.artboards.get(i) else { return d };
let r = a.rect;
@@ -385,8 +404,12 @@ fn blank_pages(doc: &Document) -> Document {
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
/// The shared Save and Export encoder for PDF-compatible `.ai` files.
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
/// same file).
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
if super::legacy_ai::wanted(cmd, p)? {
return super::legacy_ai::encode(cmd, f, doc, p);
}
let mut params = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = params.as_object_mut() {
o.insert("preserveEditing".into(), json!(true));
@@ -442,6 +465,11 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i64>) -> Result<SaveJob> {
let cmd = plan.mode.command();
let mut own = doc_to_save(st, plan.format);
// The file the document is saved as (else its own) titles it if New's name is all it has.
let file = plan.path.as_deref().filter(|_| plan.retargets()).or(st.path.as_deref());
if let Some(d) = super::titled_for_export(&own, file, plan.format.id) {
own = Arc::new(d);
}
let mut snapshot = st.doc.clone();
if let Some(at) = stamped {
stamp_doc_dates(Arc::make_mut(&mut own), at);
@@ -463,9 +491,14 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
}
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
// Save keeps every artboard; exports may choose a subset instead.
o.insert("range".into(), json!("all"));
let legacy = super::legacy_ai::wanted(cmd, &params)?;
if let (Some(o), "ai" | "ait") = (params.as_object_mut(), plan.format.id) {
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
// artboard keeps a range given: the artboards to save.
let chosen = legacy && plan.format.id == "ai" && o.get("range").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty());
if !chosen {
o.insert("range".into(), json!("all"));
}
}
Ok(SaveJob { plan, doc, params, snapshot, source: st.doc.clone(), stamped, boards })
}
@@ -506,8 +539,10 @@ impl SaveJob {
/// Encode one file of `doc` in the job's format.
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
if enc.files.len() != 1 {
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
// A legacy .ai file per artboard.
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?;
if enc.files.len() != 1 && !per_artboard {
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
}
Ok(enc)
@@ -687,7 +722,7 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active();
let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft");
let f = format(id)
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id))
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
// A native save compresses as Use Compression says, unless told otherwise.
@@ -738,7 +773,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save as Template…",
["File"],
None,
"{…as document.save} {path?, compress?, version?, preview?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
"{…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}",
has_doc,
|s, p| save(s, SaveMode::Template, p)
),
@@ -765,7 +800,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Format Options",
[],
None,
"{format?: a writable format id (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
"{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
always,
format_options
),
+387 -83
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,
};
@@ -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, 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) {
+35 -3
View File
@@ -159,8 +159,8 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(
filters.len(),
8 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
9 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, scripts, plug-ins"
);
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
@@ -236,6 +236,36 @@ fn formats_query_lists_readers_writers_and_options() {
assert_eq!(r["openExtensions"].as_array().unwrap().len(), OPEN_EXTS.len());
}
/// The format ids `command`'s params text lists for `format` (`a|b (default)|c (…)`, up to the
/// first comma), sorted.
fn listed_formats(command: &str) -> Vec<String> {
let params = crate::cmd::find_command(command).unwrap().params;
let (_, rest) = params.split_once("format?: ").unwrap_or_else(|| panic!("{command} lists no formats"));
let list = rest.split(',').next().unwrap_or_default();
let mut ids: Vec<String> = list.split('|').filter_map(|f| f.split_whitespace().next()).map(str::to_string).collect();
ids.sort();
ids
}
/// The params texts of the commands that take any writable format list exactly the formats each
/// command writes (.ai among them).
#[test]
fn export_params_list_every_format_the_command_writes() {
let mut s = session(100.0, 80.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
s.execute("select.all", &json!({})).unwrap();
for command in ["document.serialize", "document.export", "document.exportSelection"] {
let mut refused = vec![];
let mut written: Vec<String> = FORMATS
.iter()
.filter(|f| s.execute(command, &json!({"format": f.id})).map_err(|e| refused.push(format!("{}: {e}", f.id))).is_ok())
.map(|f| f.id.to_string())
.collect();
written.sort();
assert_eq!(listed_formats(command), written, "{command}; refused: {refused:?}");
}
}
#[test]
fn ranges_parse_one_based() {
assert_eq!(parse_range("1-3, 5", 5).unwrap(), [0, 1, 2, 4]);
@@ -348,7 +378,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);
}
@@ -82,6 +82,27 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
#[test]
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
// 3 × 1: red, green, blue (100 010 001), as hex samples in both parts.
let raster = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n[ 60 0 0 60 10 90 ] 3 1 0 Xh\n\
[ 60 0 0 60 10 90 ] 0 0 3 1 3 1 1 3 0 0 0 0 4 4 0 0\n%%BeginData: 6\rXI\n8880\n%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n";
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", raster));
let page = "q 180 0 0 60 10 30 cm BI /W 3 /H 1 /CS /RGB /BPC 1 /F /AHx ID 8880> EI Q";
let file = ai::ai(&compressed(data.as_bytes()), page);
let mut s = Session::new();
let r = open(&mut s, "bits.ai", &file, json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art"], "{r}");
let mut found = vec![];
doc.visit_images(|_, im| {
let img = image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgb8();
found.push(img.pixels().map(|p| p.0).collect::<Vec<_>>());
});
assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}");
}
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
#[test]
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
@@ -220,6 +241,36 @@ fn editing_data_false_opens_only_what_the_page_prints() {
assert!(e.contains("placeholder"), "{e}");
}
/// A CMYK image opens with the same ink amounts from a `.ai` file's editing data as from its PDF
/// part.
#[test]
fn a_cmyk_image_keeps_its_inks_from_the_editing_data_as_from_the_pdf_part() {
// 3 × 1: cyan, 100% black, then a mix, as hex samples in both parts.
let inks = [255u8, 0, 0, 0, 0, 0, 0, 255, 12, 34, 56, 78];
let hex: String = inks.iter().map(|b| format!("{b:02x}")).collect();
let raster = format!(
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceCMYK XN\n[ 10 0 0 10 10 60 ] 3 1 0 Xh\n\
[ 10 0 0 10 10 60 ] 0 0 3 1 3 1 8 4 0 0 0 0 4 4 0 0\n%%BeginData: {}\rXI\n{hex}\n%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n",
hex.len() + 2
);
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &raster));
let page = format!("q 30 0 0 10 10 50 cm BI /W 3 /H 1 /CS /CMYK /BPC 8 /F /AHx ID {hex}> EI Q");
let file = ai::ai(&compressed(data.as_bytes()), &page);
let opened = |s: &Session| {
let doc = &s.doc().unwrap().doc;
let mut found = vec![];
doc.visit_images(|_, im| found.push(doc.images[&im.key].cmyk().map(|i| i.data)));
found
};
let mut s = Session::new();
let r = open(&mut s, "cmyk.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(opened(&s), [Some(inks.to_vec())], "from the editing data");
let r = open(&mut s, "cmyk.ai", &file, json!({"editingData": false})).unwrap();
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(opened(&s), [Some(inks.to_vec())], "from the PDF part");
}
#[test]
fn fit_to_artwork_bounds_leaves_out_the_hidden_art_and_guides_the_editing_data_has() {
// A shown square; a hidden one and a long guide far from it, which the page doesn't draw.
@@ -252,6 +303,30 @@ 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}");
}
/// 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,
/// and `document.pdfInfo` gives the page in points (#1482).
#[test]
fn an_ai_file_with_a_user_unit_opens_at_its_size_in_points() {
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(20.0, 20.0, 60.0, 50.0))));
let file = ai::ai_with(&compressed(data.as_bytes()), "1 0 0 rg 20 20 40 30 re f", "/UserUnit 10");
let near = |r: vectorcraft_geom::Rect, want: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01);
let mut s = Session::new();
for (params, layers) in [(json!({}), ["Art"]), (json!({"editingData": false}), ["Page 1"])] {
let r = open(&mut s, "large.ai", &file, params.clone()).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), layers, "{params}: {r}");
assert!(near(doc.artboards[0].rect, [0.0, 0.0, 2000.0, 1000.0]), "{params}: {:?}", doc.artboards[0].rect);
let art = doc.art_bounds().unwrap();
assert!(near(art, [200.0, 500.0, 600.0, 800.0]), "{params}: {art:?}");
}
let v = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
let page = &v["pageInfo"][0];
assert_eq!((page["width"].as_f64(), page["height"].as_f64()), (Some(2000.0), Some(1000.0)), "{v}");
assert_eq!(page["boxes"]["media"], json!([0.0, 0.0, 2000.0, 1000.0]), "{v}");
}
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
/// as in the editing data. `key`: `AIPrivateData`, or `AIPDFPrivateData` for a PDF saved with its
+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).
@@ -65,6 +65,34 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() {
assert_eq!(s.doc().unwrap().title(), "plain.eps");
}
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
/// it is placed, with its transparent pixel as its opacity mask.
#[test]
fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 60 20\n%%EndComments\n/DeviceCMYK setcolorspace 60 20 scale \
<< /ImageType 4 /Width 2 /Height 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [2 0 0 -1 0 1] \
/MaskColor [10 20 30 40] /DataSource <0a141e28 00667f08> >> image\nshowpage\n%%EOF\n";
// Each image object's ink amounts (`None` when it isn't CMYK) and whether it has a mask.
let images = |s: &Session| {
let d = &s.doc().unwrap().doc;
let mut found = vec![];
d.walk(|n| {
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
found.push((d.images[&im.key].cmyk().map(|i| i.data), n.extras().mask.is_some()));
}
});
found
};
let want = (Some(vec![10, 20, 30, 40, 0, 102, 127, 8]), true);
let mut s = Session::new();
let r = open(&mut s, "cmyk.eps", eps).unwrap();
assert_eq!(r["format"], "eps", "{r}");
assert_eq!(images(&s), std::slice::from_ref(&want));
let r = s.execute("file.place", &json!({"name": "cmyk.eps", "dataBase64": vectorcraft_format::base64_encode(eps)})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(images(&s), [want.clone(), want]);
}
#[test]
fn an_unreadable_eps_file_comes_in_as_its_preview() {
let mut s = Session::new();
@@ -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();
@@ -153,6 +174,33 @@ fn ait_templates_open_their_document_untitled() {
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
/// Save as Template writes a PDF-compatible `.ait` for a `.ait` path or `format: ait`, and New
/// from Template opens it as an untitled copy of the document, layers and art included.
#[test]
fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
let mut s = rich();
s.execute("layer.new", &json!({"name": "Notes"})).unwrap();
s.execute("shape.ellipse", &json!({"x": 20, "y": 120, "width": 40, "height": 30})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
assert_eq!(before.layers.len(), 2);
let path = tmp("brochure template.ait");
let r = s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().is_dirty()), (None, true), "the document is unchanged");
let r = s.execute("file.newFromTemplate", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert_eq!(comparable(&s.doc().unwrap().doc), before);
// Without a path: the bytes and a name for the Templates folder.
let r = s.execute("file.saveAsTemplate", &json!({"format": "ait"})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
assert!(r["name"].as_str().is_some_and(|n| n.ends_with(" template.ait")), "{r}");
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
}
#[test]
fn standards_and_screens_leave_the_editing_data_out() {
let mut s = rich();
Loaded 100 of 525 files, more files were not shown because too many files have changed in this diff. Show more