Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 ed80181d70 #1163: command_batch can be one undo step
An agent's build over MCP left one undo step per batch step (about 280
for one job). `command_batch` takes `atomic: true` (and an optional
`label` naming the step) to make the whole batch one undo step. It runs
the batch in an undo group, opened and closed by the new commands
`edit.undoGroup.begin {label?}` and `edit.undoGroup.end {cancel?}`, so
every backend (headless or the connected app) supports it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PenJoin is a crosshair too under the preference.

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

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

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

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

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

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

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:35 +07:00
skylar taylor-barrick ed14640756 UI.9: integrate shared docking and preserve panel workflows 2026-10-10 14:49:35 -06:00
237 changed files with 14762 additions and 1511 deletions

No files matched your search

+81 -1
View File
@@ -377,11 +377,91 @@ jobs:
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@v7
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
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@v7
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
name: Draft GitHub Release
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
# drafts a release.
needs: [version, macos, windows, linux, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
+1
View File
@@ -98,6 +98,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | The Selection and Direct Selection arrows' fill, painted black or white under their outline |
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
Generated
+145 -29
View File
@@ -200,6 +200,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.6.1"
@@ -713,6 +719,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -790,6 +806,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -861,6 +886,19 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1066,6 +1104,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -1438,6 +1497,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1940,6 +2008,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2067,6 +2154,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "krilla"
version = "0.8.2"
@@ -2119,6 +2216,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2167,9 +2270,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linesweeper"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
dependencies = [
"arrayvec",
"kurbo 0.13.1",
@@ -2740,6 +2843,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4320,7 +4433,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4335,6 +4448,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"serde_json",
@@ -4350,7 +4464,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4361,7 +4475,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4373,7 +4487,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4389,7 +4503,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4410,7 +4524,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4422,7 +4536,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4439,7 +4553,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4454,7 +4568,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4489,7 +4603,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"image",
@@ -4509,7 +4623,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"proptest",
@@ -4523,7 +4637,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4535,7 +4649,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"log",
@@ -4554,7 +4668,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4568,7 +4682,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4580,7 +4694,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"aes",
"flate2",
@@ -4609,7 +4723,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
@@ -4623,7 +4737,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"gif",
@@ -4648,7 +4762,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"image",
@@ -4672,7 +4786,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4689,7 +4803,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4707,14 +4821,14 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-tools"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4729,7 +4843,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4742,10 +4856,12 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-ui",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
"serde",
@@ -4773,7 +4889,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -5883,7 +5999,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+3 -2
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -61,7 +62,7 @@ usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
linesweeper = "0.5"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
+4 -3
View File
@@ -170,12 +170,13 @@ next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open;
to place artwork in the current document, copy and paste the file in a file manager; or run
under XWayland (`WAYLAND_DISPLAY= vectorcraft`) for file drag-and-drop
to place artwork in the current document, copy and paste the file in a file manager. For file
drag-and-drop, set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart, or start
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
+5 -1
View File
@@ -1,5 +1,9 @@
# VectorCraft Roadmap
- 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-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
@@ -123,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+39 -8
View File
@@ -94,6 +94,15 @@ fn mcp_automation_roots() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
std::fs::write(base.join("in/a.svg"), svg).unwrap();
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
std::fs::write(base.join("outside/victim.png"), b"keep this file").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(base.join("outside/b.svg"), base.join("in/escape.svg")).unwrap();
symlink(base.join("outside"), base.join("out/escape")).unwrap();
symlink(base.join("outside/victim.png"), base.join("out/victim.png")).unwrap();
symlink(base.join("outside/missing.png"), base.join("out/dangling.png")).unwrap();
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let fails = |args: &[&str], why: &str| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
@@ -114,15 +123,32 @@ fn mcp_automation_roots() {
.unwrap();
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
let mut calls = vec![
call(1, "open_file", json!({"path": file("in", "a.svg")})),
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
call(3, "export", json!({"path": file("out", "a.png")})),
call(4, "export", json!({"path": file("in", "a.png")})),
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
call(6, "open_file", json!({"path": file("in", "../outside/b.svg")})),
call(7, "export", json!({"path": file("out", "../outside/traversal.png")})),
];
let mut expected_errors = vec![false, true, false, true, true, true, true];
#[cfg(unix)]
{
calls.extend([
call(8, "open_file", json!({"path": file("in", "escape.svg")})),
call(9, "export", json!({"path": file("out", "escape/escaped.png")})),
call(10, "export", json!({"path": file("out", "victim.png")})),
call(11, "export", json!({"path": file("out", "dangling.png")})),
]);
expected_errors.extend([true; 4]);
}
// An access error is per request: the server continues and can still save inside its root.
calls.push(call(12, "save_file", json!({"path": file("out", "allowed.vectorcraft")})));
expected_errors.push(false);
{
let mut stdin = child.stdin.take().unwrap();
for m in [
call(1, "open_file", json!({"path": file("in", "a.svg")})),
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
call(3, "export", json!({"path": file("out", "a.png")})),
call(4, "export", json!({"path": file("in", "a.png")})),
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
] {
for m in calls {
writeln!(stdin, "{m}").unwrap();
}
}
@@ -130,9 +156,14 @@ fn mcp_automation_roots() {
assert!(out.status.success());
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, true, false, true, true], "{replies:?}");
assert_eq!(errors, expected_errors, "{replies:?}");
assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists());
assert!(base.join("out/allowed.vectorcraft").is_file());
for denied in ["traversal.png", "escaped.png", "missing.png"] {
assert!(!base.join("outside").join(denied).exists(), "{denied}");
}
assert_eq!(std::fs::read(base.join("outside/victim.png")).unwrap(), b"keep this file");
}
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
+2 -1
View File
@@ -308,7 +308,8 @@ fn notice(lines: &[&str]) {
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
+3
View File
@@ -63,6 +63,9 @@ raw-window-handle = "0.6"
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
# already in the build through eframe.
pollster = "1"
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
+90
View File
@@ -0,0 +1,90 @@
//! Parse automation roots before starting any desktop services. A malformed flag must not
//! silently remove a root and leave the control channel unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. CLI values override environment defaults
/// once; a second occurrence is an error, including when the two spellings are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_override_environment_once_and_leave_other_arguments() {
let mut roots = AutomationArgs::new(Some("environment".into()), None);
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_preserve_environment_roots() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--control"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
+287 -56
View File
@@ -21,6 +21,7 @@ compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinar
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
compile_error!("the win7 target requires --no-default-features --features windows7");
mod automation_args;
mod clipboard;
mod control_server;
#[cfg(feature = "wgpu")]
@@ -39,6 +40,7 @@ mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod window;
mod xwayland;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
@@ -80,8 +82,9 @@ impl eframe::App for App {
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
discard_marked_text();
}
}
@@ -114,8 +117,16 @@ impl App {
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
#[cfg(target_os = "macos")]
open_files(&mut self.app, open_documents::take());
if !self.app.file_dialog_open() {
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
if !self.app.file_dialog_open() {
open_files(&mut self.app, open_documents::take());
}
}
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.app.ui.status == "quit" {
@@ -189,7 +200,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
serde_json::from_slice(&bytes).ok()
decode_saved_prefs(&bytes)
}
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
use serde_json::{Map, Value, json};
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
if let Some(layout) = object.get("docking")
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
{
object.remove("docking");
}
if let Some(hidden) = object.get_mut(hidden_key) {
if let Value::Object(entries) = hidden {
entries.retain(|panel, location| {
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
});
} else {
object.remove(hidden_key);
}
}
}
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
serde_json::from_value(value).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
@@ -220,48 +266,99 @@ fn save_prefs(app: &VectorcraftApp) {
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
/// shows it over the window and keeps it in front; macOS shows them as before).
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
let d = match parent {
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
_ => rfd::FileDialog::new(),
};
/// What rfd's blocking and asynchronous file dialogs are both set up with.
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[&str]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
macro_rules! dialog_settings {
($($dialog:ty),*) => {$(
impl DialogSettings for $dialog {
fn parent(self, window: &winit::window::Window) -> Self {
self.set_parent(window)
}
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[&str]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
self.set_directory(folder)
}
fn file_name(self, name: &str) -> Self {
self.set_file_name(name)
}
}
)*};
}
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
PickRequest::OpenMany => return fileio::place_filters().fold(d.title("Place"), |d, (name, exts)| d.filter(name, exts)),
PickRequest::Folder => return d,
};
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.filter(name, exts));
let d = match &pick.folder {
Some(folder) => d.set_directory(folder),
Some(folder) => d.folder(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
}
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
/// window and keeps it in front).
#[cfg(not(target_os = "macos"))]
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
match parent {
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
None => dialog_for(rfd::FileDialog::new(), request),
}
}
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
fn make_folder(request: &PickRequest) {
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
let _ = std::fs::create_dir_all(folder);
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
#[cfg(not(target_os = "macos"))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
make_folder(request);
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(pick) => {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
dialog.save_file().into_iter().collect()
}
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
#[cfg(target_os = "macos")]
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
Vec::new()
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
@@ -297,34 +394,97 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
/// inside the window's event handler, and resizing it delivered window events to that handler
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
/// from the run loop, outside the handler.
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
#[cfg(not(target_os = "macos"))]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request));
ui.request_repaint();
})
.ok()?;
Some(rx)
}
fn services(parent: Parent) -> Services {
/// The paths a dialog shown as a sheet picks, once it closes.
#[cfg(target_os = "macos")]
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
/// returns at once and the window keeps running; a thread of its own waits for the answer.
#[cfg(target_os = "macos")]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
use winit::raw_window_handle::HasWindowHandle as _;
// rfd runs the panel modally after all unless it has the running app's window to hold the
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
let mtm = objc2::MainThreadMarker::new();
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
return None;
};
// The thread first, so the answer of a sheet once shown always has somewhere to go.
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(pollster::block_on(answer));
ui.request_repaint();
}
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
PickRequest::Open(_) => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::OpenMany => {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(_) => {
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
};
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
let _ = sheet_tx.send(answer);
Some(rx)
}
/// The host's services; `ui` is woken when a file dialog answers.
fn services(parent: Parent, ui: egui::Context) -> Services {
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
// ones re-enter the window's event handler (#867).
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -427,7 +587,36 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
}
fn main() -> std::process::ExitCode {
// First, so every start-up warning is recorded (`logging`).
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut roots_args =
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args) {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
}
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
}
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first font scan (the app's start): the fonts font services load (#579).
@@ -437,28 +626,6 @@ fn main() -> std::process::ExitCode {
mac_fonts::install();
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
prefs_dir::install_fonts();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut read_root = root_env("VECTORCRAFT_AUTOMATION_READ_ROOT");
let mut write_root = root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT");
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--automation-read-root" => read_root = args.next().map(std::path::PathBuf::from),
"--automation-write-root" => write_root = args.next().map(std::path::PathBuf::from),
_ if a.starts_with("--automation-read-root=") => read_root = a.split_once('=').map(|(_, d)| d.into()),
_ if a.starts_with("--automation-write-root=") => write_root = a.split_once('=').map(|(_, d)| d.into()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
@@ -516,6 +683,25 @@ fn main() -> std::process::ExitCode {
let startup = std::sync::Arc::new(gpu::Startup::default());
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
if xwayland::x11_libraries_found() {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
}));
} else {
log::warn!("Linux Display Server is X11, but the X11 libraries aren't all installed: opening the window on Wayland");
}
}
options
};
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
@@ -549,7 +735,7 @@ fn main() -> std::process::ExitCode {
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
@@ -649,6 +835,51 @@ mod tests {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
#[test]
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
use serde_json::json;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
app.ui.toolbar = false;
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
let mut raw = original.clone();
raw["docking"] = invalid_layout.clone();
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
assert!(!loaded.toolbar);
assert!(loaded.docking.is_none());
assert!(!loaded.docking_hidden.contains_key("layers"));
if invalid_hidden.is_object() {
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
} else {
assert!(loaded.docking_hidden.is_empty());
}
assert_eq!(loaded.custom_workspaces.len(), 2);
assert_eq!(loaded.custom_workspaces[0].name, "Future");
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
assert!(
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
"unrelated fields retain normal strict parsing"
);
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// Preferences › User Interface › System Title Bar gives the window back its system
+5 -11
View File
@@ -4,21 +4,15 @@
use std::path::PathBuf;
#[path = "profile.rs"]
mod profile;
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
/// BSD).
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
let base = if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join(lower))
};
base.map(|b| b.join("ui.json"))
let legacy = name == "DrawCraft" || lower == "drawcraft";
profile::prefs_path(legacy)
}
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
+66
View File
@@ -0,0 +1,66 @@
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
use std::ffi::OsString;
use std::path::PathBuf;
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
}
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
}
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
let base = match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
};
base.map(|p| p.join("ui.json"))
}
#[cfg(test)]
mod tests {
use super::*;
fn environment(key: &str) -> Option<OsString> {
match key {
"HOME" => Some("/user".into()),
"APPDATA" => Some("/roaming".into()),
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
_ => None,
}
}
#[test]
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
for os in ["macos", "windows", "linux"] {
let env = |key: &str| {
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
};
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env), None);
}
}
#[test]
fn empty_override_does_not_fall_back_to_user_files() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
assert_eq!(prefs_path_with("macos", false, env), None);
assert_eq!(prefs_path_with("macos", true, env), None);
}
#[test]
fn unset_override_keeps_platform_paths_and_legacy_migration() {
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
Some("/user/.config/drawcraft/ui.json".into())
);
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
//! never keeps the app from starting.
//!
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// What winit 0.30 `dlopen`s for an X11 window: x11-dl opens Xlib, Xlib-xcb, Xcursor and XInput2,
/// x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11.
const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
/// and an X display to open.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
}
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
/// count as missing.
pub fn x11_libraries_found() -> bool {
let cache = read_ldconfig();
let dirs = search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref());
X11_LIBS
.iter()
.all(|lib| cache.as_deref().is_some_and(|out| ldconfig_has(out, lib, ldconfig_arch_tag())) || dirs.iter().any(|d| d.join(lib).exists()))
}
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
fn ldconfig_arch_tag() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86-64")
} else if cfg!(target_arch = "aarch64") {
Some("AArch64")
} else {
None
}
}
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
output.lines().any(|line| {
let Some((name, rest)) = line.trim_start().split_once(' ') else { return false };
if name != soname {
return false;
}
match arch_tag {
None => true,
Some(tag) => {
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
flags.split(',').any(|f| f.trim() == tag)
}
}
})
}
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian). A
/// real listing starts with "N libs found in cache".
fn read_ldconfig() -> Option<String> {
["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"].into_iter().find_map(|cmd| {
let out = std::process::Command::new(cmd).arg("-p").output().ok().filter(|out| out.status.success())?;
let text = String::from_utf8_lossy(&out.stdout).into_owned();
text.lines().next().is_some_and(|l| l.contains("libs found in cache")).then_some(text)
})
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") {
"aarch64-linux-gnu"
} else if cfg!(target_arch = "riscv64") {
"riscv64-linux-gnu"
} else {
"x86_64-linux-gnu"
};
dirs.extend(["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"].map(PathBuf::from));
dirs.push(Path::new("/usr/lib").join(triplet));
dirs.push(Path::new("/lib").join(triplet));
dirs
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
}
#[test]
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
assert!(!wanted(Some("automatic"), &wayland));
assert!(!wanted(None, &wayland));
assert!(!wanted(Some("wayland"), &wayland));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
}
#[test]
fn ldconfig_parsing_matches_name_and_architecture() {
let cache = "12 libs found in cache `/etc/ld.so.cache'\n\
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n";
assert!(ldconfig_has(cache, "libX11.so.6", Some("x86-64")));
assert!(!ldconfig_has(cache, "libX11.so.6", Some("AArch64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(cache, "libxkbcommon-x11.so.0", Some("x86-64")));
assert!(ldconfig_has(cache, "libxkbcommon-x11.so.0", None));
assert!(!ldconfig_has(cache, "libX11.so", None));
}
}
+70
View File
@@ -0,0 +1,70 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Invalid roots must exit before desktop initialization on every platform, including a CI
/// machine with no display. A regression must fail rather than hang with a window open.
fn rejects(args: &[&str], message: &str) {
let mut child = Command::new(BIN)
.args(args)
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
.env("VECTORCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
}
if Instant::now() >= deadline {
child.kill().unwrap();
break true;
}
std::thread::sleep(Duration::from_millis(10));
};
let out = child.wait_with_output().unwrap();
assert!(!timed_out, "{args:?}: desktop started or did not exit promptly");
assert!(!out.status.success(), "{args:?}");
assert!(out.stdout.is_empty(), "{args:?}: {:?}", out.stdout);
// Exact stderr excludes unrelated startup warnings or display errors that could otherwise
// make a malformed invocation fail for an unrelated reason.
assert_eq!(String::from_utf8(out.stderr).unwrap(), format!("vectorcraft: {message}\n"), "{args:?}");
}
#[test]
fn missing_empty_or_option_operands_fail_before_startup() {
for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&["--control", "7979", flag], &format!("{flag} needs a folder"));
rejects(&[flag], &format!("{flag} needs a folder"));
rejects(&["--control", "7979", &format!("{flag}=")], &format!("{flag} needs a non-empty folder"));
rejects(&[flag, ""], &format!("{flag} needs a non-empty folder"));
for next in ["--version", "--control", "--automation-write-root", "-folder"] {
rejects(&[flag, next], &format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
}
}
#[test]
fn duplicate_roots_fail_for_both_spellings() {
for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
}
}
#[test]
fn explicit_dash_folder_does_not_consume_version() {
let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 173 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor" stroke-width="1"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="none"/></svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 265 B

+15
View File
@@ -17,6 +17,9 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.lollinoo]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -58,3 +61,15 @@ sync_github_name = false
real_name = "Fabio William Conceição"
display_name = "Messhias"
sync_github_name = false
[people.KattKnight]
display_name = "KattKnight (ayla.dsc)"
sync_github_name = false
[people.NoJuanNobody]
real_name = "Alejandro Londono"
display_name = "alex"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
+4 -1
View File
@@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) {
/// The name of `$INSUNITS` code `code`.
fn units_name(code: i64) -> &'static str {
const NAMES: [&str; 22] = [
const NAMES: [&str; 25] = [
"Unitless",
"Inches",
"Feet",
@@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str {
"Light years",
"Parsecs",
"US survey feet",
"US survey inches",
"US survey yards",
"US survey miles",
];
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
}
+6 -1
View File
@@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> {
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
let m = 72.0 / 0.0254;
// The US survey foot: exactly 1200/3937 m.
let survey_ft = m * 1200.0 / 3937.0;
Some(match code {
1 => 72.0,
2 => 864.0,
@@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option<f64> {
18 => m * 1.495_978_707e11,
19 => m * 9.460_730_472_580_8e15,
20 => m * 3.085_677_581e16,
21 => m * 1200.0 / 3937.0,
21 => survey_ft,
22 => survey_ft / 12.0,
23 => survey_ft * 3.0,
24 => survey_ft * 5280.0,
_ => return None,
})
}
+14
View File
@@ -128,6 +128,20 @@ fn units_choose_the_default_scale() {
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
assert!((um.scale - 1000.0).abs() < 1e-6);
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
for (code, name, pt) in [
("21", "US survey feet", survey_ft),
("22", "US survey inches", survey_ft / 12.0),
("23", "US survey yards", survey_ft * 3.0),
("24", "US survey miles", survey_ft * 5280.0),
] {
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
}
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
+41 -1
View File
@@ -57,6 +57,19 @@ impl PaletteFormat {
pub fn binary(self) -> bool {
self == PaletteFormat::Ase
}
/// Does [`write`] keep a swatch with `paint` in a file of this format? None and patterns are
/// never written; `.gpl` and `.ase` hold solid colors only, and CSS holds no freeform gradients.
pub fn holds(self, paint: &Paint) -> bool {
match paint {
Paint::Solid { .. } => true,
Paint::Gradient(g) => match self {
PaletteFormat::Native => true,
PaletteFormat::Css => g.gradient.kind != GradientKind::Freeform,
PaletteFormat::Gpl | PaletteFormat::Ase => false,
},
Paint::None | Paint::Pattern { .. } => false,
}
}
}
/// The native file: a header around the library.
@@ -88,7 +101,7 @@ const ACB_CUT_SHORT: &str = "the color book is cut short";
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
/// [`read_bytes`] reads from one file.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
let keep = |w: &&Swatch| format.holds(&w.paint);
let text = match format {
PaletteFormat::Native => {
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
@@ -792,6 +805,23 @@ mod tests {
assert!(read(&text, "x").is_err(), "CSS is written only");
}
#[test]
fn holds_counts_the_swatches_each_format_writes() {
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
let mut lib = sample();
let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") };
mesh.gradient.kind = GradientKind::Freeform;
lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false });
lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false });
let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count();
assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css");
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}");
}
let css = written(&lib, PaletteFormat::Css);
assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}");
}
/// A swatch exchange file of `blocks` (type, body).
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut out = b"ASEF\0\x01\0\0".to_vec();
@@ -1088,5 +1118,15 @@ mod tests {
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
}
}
/// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as
/// [`PaletteFormat::holds`] counts.
#[test]
fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) {
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap();
prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f);
}
}
}
}
+14
View File
@@ -375,6 +375,20 @@ mod tests {
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
}
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
/// outline it doesn't touch.
#[test]
fn a_marquee_inside_a_background_leaves_the_background() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (back, small) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
// Reaching the background's edge takes it too.
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
}
#[test]
fn group_top_object() {
let mut d = Document::new(200.0, 200.0);
+73 -1
View File
@@ -402,12 +402,17 @@ pub struct Guide {
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artboard: Option<u32>,
/// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed
/// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides
/// from before guides had layers, and from files that don't say), always there.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer: Option<NodeId>,
}
impl Guide {
/// A canvas guide.
pub fn new(vertical: bool, pos: f64) -> Self {
Self { vertical, pos, artboard: None }
Self { vertical, pos, artboard: None, layer: None }
}
/// Moved by `d` (a vertical guide across, a horizontal one down).
@@ -990,6 +995,73 @@ impl Document {
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
}
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
}
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
/// visible? A guide whose layer is gone shows as one on no layer.
pub fn guide_shown(&self, g: &Guide) -> bool {
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
}
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
pub fn guide_editable(&self, g: &Guide) -> bool {
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
}
/// Put the ruler guides on no layer this document has (from files saved before guides had
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
let mut n = 0;
for (g, lost) in self.guides.iter_mut().zip(lost) {
if lost {
g.layer = Some(layer);
n += 1;
}
}
n
}
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
pub fn adopt_guides(&mut self) -> usize {
self.default_layer().map_or(0, |home| self.rehome_guides(home))
}
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
/// step. Guides on no layer stay. Returns how many went.
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
}
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
/// the objects on it. Returns how many.
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
let layers = |n: &Node| {
let mut out = vec![];
n.walk(&mut |c| {
if c.is_layer() {
out.push(c.id);
}
});
out
};
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
let copies: Vec<Guide> = self
.guides
.iter()
.filter_map(|g| {
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
Some(Guide { layer: Some(to), ..g.clone() })
})
.collect();
let n = copies.len();
self.guides.extend(copies);
n
}
/// The ruler guides on layer `layer` (not its sublayers'), by index.
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
}
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
let along = if g.vertical { p.y } else { p.x };
+116 -7
View File
@@ -16,7 +16,9 @@ pub struct Selection {
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
/// Key object for Align.
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
/// it when every selected object is the key or lies inside it, as when one object is left
/// selected or when the key is a group selected with only its own members.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<NodeId>,
/// The object, group or layer targeted through its target circle in the Layers panel, which
@@ -29,8 +31,9 @@ pub struct Selection {
/// clears them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slices: Vec<NodeId>,
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are
/// selected on their own: any change to the object selection clears them.
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new
/// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so
/// guides and objects can be selected together, in either order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<usize>,
}
@@ -66,7 +69,6 @@ impl Selection {
pub fn add(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
self.guides.clear();
if !self.objects.contains(&id) {
self.objects.push(id);
}
@@ -74,7 +76,6 @@ impl Selection {
pub fn remove(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
self.guides.clear();
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
if self.key == Some(id) {
@@ -99,6 +100,15 @@ impl Selection {
}
}
}
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
/// Shift-click on a guide).
pub fn toggle_guide(&mut self, i: usize) {
if let Some(k) = self.guides.iter().position(|g| *g == i) {
self.guides.remove(k);
} else {
self.guides.push(i);
}
}
pub fn toggle(&mut self, id: NodeId) {
if self.contains(id) { self.remove(id) } else { self.add(id) }
}
@@ -110,14 +120,32 @@ impl Selection {
pub fn prune(&mut self, doc: &Document) {
self.objects.retain(|id| doc.node(*id).is_some());
self.anchors.retain(|id, _| doc.node(*id).is_some());
if self.key.is_some_and(|k| doc.node(k).is_none()) {
// The key object stays while another selected object lies outside it.
let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| {
let mut inside = BTreeSet::new();
n.walk(&mut |c| {
inside.insert(c.id);
});
self.objects.iter().any(|o| !inside.contains(o))
});
if !keep {
self.key = None;
}
if self.target.is_some_and(|t| doc.node(t).is_none()) {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));
self.guides.retain(|i| *i < doc.guides.len());
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
}
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
/// around them (and so the key object when it is one): hidden or locked art can't stay
/// selected.
pub fn deselect_uneditable(&mut self, doc: &Document) {
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
for o in out {
self.remove(o);
}
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
/// (the art of its sublayers too), anything else is selected itself.
@@ -180,8 +208,16 @@ mod tests {
s.set_guides([2, 0, 2]);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
assert!(s.has_objects_or_guides() && s.is_empty());
// Shift-adding an object keeps them; a new selection doesn't.
s.add(NodeId(1));
assert_eq!(s.guides, vec![2, 0]);
s.set([NodeId(1)]);
assert!(s.guides.is_empty());
// Toggled, a guide joins (or leaves) the selected objects.
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
// Pruning drops guides the document no longer has.
let mut d = Document::new(100.0, 100.0);
d.guides.push(crate::Guide::new(true, 10.0));
@@ -190,6 +226,36 @@ mod tests {
assert_eq!(s.guides, vec![0]);
}
#[test]
fn guides_show_and_lock_with_their_layer() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
assert!(d.guide_shown(&g) && d.guide_editable(&g));
d.node_mut(layer).unwrap().locked = true;
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
d.node_mut(layer).unwrap().visible = false;
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
// On no layer, or a layer that's gone: always there.
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
d.guides.push(g);
assert_eq!(d.guides_on(layer).count(), 1);
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
d.node_mut(layer).unwrap().visible = true;
d.node_mut(layer).unwrap().locked = false;
d.guides.push(crate::Guide::new(false, 5.0));
d.guides.push(gone);
assert_eq!(d.adopt_guides(), 2);
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
assert_eq!(d.adopt_guides(), 0, "once");
// A guide saved before guides had layers reads as one on no layer.
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
assert_eq!(old.layer, None);
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
@@ -206,4 +272,47 @@ mod tests {
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
s.set([NodeId(1), NodeId(2)]);
s.key = Some(NodeId(2));
s.remove(NodeId(1));
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
s.remove(NodeId(2));
assert_eq!(s.key, None, "the key object itself left");
// Pruning drops objects the document no longer has and clears the key once no other
// selected object lies outside it.
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for _ in 0..4 {
let id = d.alloc_id();
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
}
s.set(ids.iter().copied());
s.key = Some(ids[3]);
d.remove(ids[0]).unwrap();
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
s.toggle(ids[1]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
s.toggle(ids[2]);
s.prune(&d);
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
// A group key selected with only its own member is cleared too; another selected object
// outside the group keeps it.
let member = d.alloc_id();
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
s.set([member, ids[3]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, None, "a group and its own member");
s.set([member, ids[3], ids[2]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
}
}
+61
View File
@@ -95,6 +95,11 @@ pub struct CharStyle {
/// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
@@ -205,6 +210,7 @@ impl Default for CharStyle {
stroke_dash: None,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
small_caps: None,
}
}
@@ -954,7 +960,49 @@ pub struct TextObject {
pub cached_baselines: Vec<(Point, Point)>,
}
/// Conversion from stored character attributes to document-space type controls.
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
#[derive(Clone, Copy, Debug)]
pub struct TextStyleScale {
pub points: f64,
pub horizontal: f64,
}
impl TextObject {
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
/// be edited by compensating the stored attributes.
pub fn style_scale(&self) -> Option<TextStyleScale> {
let [a, b, c, d, e, f] = self.xf.as_coeffs();
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
return None;
}
let (sx, sy) = (a.hypot(b), c.hypot(d));
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
let horizontal = sx / sy;
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
return None;
}
// Avoid a pure rotation turning otherwise identical fields into mixed values.
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
}
/// Character attributes as presented by controls (stored runs stay local for layout
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
let scale = self.style_scale()?;
let mut style = style.clone();
style.size *= scale.points;
style.leading = style.leading.map(|v| v * scale.points);
style.baseline_shift *= scale.points;
style.h_scale *= scale.horizontal;
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
return None;
}
Some(style)
}
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
@@ -1190,6 +1238,19 @@ impl TextObject {
mod tests {
use super::*;
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
t.xf = Affine::new(matrix);
assert!(t.effective_char_style(&t.first_style()).is_none());
}
t.xf = Affine::rotate(0.83);
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
t.xf = Affine::scale(1e308);
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
}
#[test]
fn point_text_basics() {
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
+19 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub(crate) use resources::SwatchChoices;
use super::*;
@@ -248,6 +248,24 @@ mod tests {
}
}
/// The image markup some browsers put as text next to a copied picture names it too (#1089);
/// markup with words in it is text.
#[test]
fn markup_of_only_images_names_a_picture() {
for m in [
r#"<img src="https://static.example.com/eagle.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#,
r#"<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="a.png">"#,
"<html><body><!--StartFragment--><IMG SRC=\"a.png\"/><!--EndFragment--></body></html>",
r#"<a href="https://x.org"><img src="a.png"></a>"#,
"https://example.com/cat.png",
] {
assert!(names_a_picture(m), "{m}");
}
for t in ["<p>Hello</p>", r#"<img src="a.png"> and a caption"#, "<b>bold</b>", "<imgx>", "<img", "plain words", "<br>", ""] {
assert!(!names_a_picture(t), "{t}");
}
}
#[test]
fn copy_exports_svg_that_round_trips_through_paste() {
let mut s = session();
@@ -39,6 +39,30 @@ pub fn is_address(text: &str) -> bool {
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
}
/// Is `text` only a reference to a picture: an address ([`is_address`]), or markup that shows
/// nothing but images (`<img …>`)? A browser's Copy Image puts one of them next to the picture
/// (some put the image's HTML there as text, #1089): then the picture is what Paste takes.
pub fn names_a_picture(text: &str) -> bool {
is_address(text) || only_images(text)
}
/// Is `html` markup whose only content is `<img>` elements: no text outside its tags (whitespace
/// between them aside), and at least one image?
fn only_images(html: &str) -> bool {
let mut images = 0;
let mut rest = html.trim();
while !rest.is_empty() {
let Some(tag) = rest.strip_prefix('<') else { return false };
let Some(end) = tag.find('>') else { return false };
let name = tag.get(..end).unwrap_or_default().split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default();
if name.eq_ignore_ascii_case("img") {
images += 1;
}
rest = tag.get(end + 1..).unwrap_or_default().trim_start();
}
images > 0
}
/// How much of a copied file [`file_flavour`] needs to tell what it is.
pub const FILE_HEAD: usize = 4096;
+11 -7
View File
@@ -219,11 +219,13 @@ fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc:
Ok(st)
}
/// The `index` param, checked against the points of `prof`.
/// The `index` param (none when absent or null), checked against the points of `prof`: a malformed
/// one is refused like one out of range, never wrapped round or taken as missing.
fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result<Option<usize>> {
match p.get("index").and_then(Value::as_u64).map(|v| v as usize) {
Some(i) if i >= prof.points.len() => Err(bad(cmd, "no such width point")),
i => Ok(i),
let Some(v) = p.get("index").filter(|v| !v.is_null()) else { return Ok(None) };
match point_indices(&json!([v])).and_then(|i| i.first().copied()) {
Some(i) if i < prof.points.len() => Ok(Some(i)),
_ => Err(bad(cmd, "no such width point")),
}
}
@@ -323,11 +325,13 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.remove";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut indices: Vec<usize> = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) {
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(),
(None, Some(i)) => vec![i as usize],
let indices = match (p.get("indices"), p.get("index")) {
(Some(a), _) => point_indices(a),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(C, "missing index")),
};
// A malformed index fails the whole delete instead of being dropped while the others go.
let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?;
indices.sort_unstable();
indices.dedup();
s.edit("Delete Width Point", |d, _| {
+71 -14
View File
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Guide",
[],
None,
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}",
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}",
has_doc,
guide_add
),
@@ -169,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Guides",
[],
None,
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides",
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides",
has_doc,
guide_list
),
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Guides",
[],
None,
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}",
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
guides_unlocked,
guide_select
),
@@ -200,6 +200,15 @@ pub fn specs() -> Vec<CommandSpec> {
guides_unlocked,
guide_move
),
cmd!(
"guide.setLayer",
"Move Guide to Layer",
[],
None,
"{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}",
guides_unlocked,
guide_set_layer
),
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
cmd!(
"file.documentColorMode",
@@ -507,8 +516,15 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
let st = s.doc()?;
let layer = match id_param(p, "layer") {
Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l),
Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")),
// Where new art goes (refused when no layer is shown and unlocked), or that group's layer.
None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }),
};
let i = s.edit("New Guide", |d, _| {
d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) });
d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) });
Ok(d.guides.len() - 1)
})?;
Ok(json!({ "index": i }))
@@ -521,6 +537,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
r["artboard"] = json!(ab);
}
if let Some(l) = g.layer {
r["layer"] = json!(l.0);
}
r["shown"] = json!(st.doc.guide_shown(g));
r["editable"] = json!(st.doc.guide_editable(g));
r
};
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
@@ -535,19 +556,26 @@ fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
Ok(i)
}
/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
let i = guide_index(s, v, cmd)?;
let d = &s.doc()?.doc;
if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) {
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
}
Ok(i)
}
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
if bool_or(p, "toggle", false) {
let mut now = s.doc()?.selection.guides.clone();
for i in picked {
if let Some(k) = now.iter().position(|g| *g == i) {
now.remove(k);
} else {
now.push(i);
// Added to (or taken from) what is selected, the art included.
s.select(|_, sel| {
for i in picked {
sel.toggle_guide(i);
}
}
s.select(|_, sel| sel.set_guides(now))?;
})?;
} else {
s.select(|_, sel| sel.set_guides(picked))?;
}
@@ -557,7 +585,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
let gone: Vec<usize> = match p.get("index") {
Some(v) => vec![guide_index(s, Some(v), "guide.remove")?],
Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
None => s.doc()?.selection.guides.clone(),
};
if gone.is_empty() {
@@ -571,10 +599,39 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": gone.len() }))
}
/// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step.
fn guide_set_layer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "guide.setLayer";
let d = &s.doc()?.doc;
let layer =
id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?;
// As objects dragged in the Layers panel: never onto a locked layer.
if super::layer::locked_within(d, layer) {
return Err(EngineError::Other("the layer or group is locked".into()));
}
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
(Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
(None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::<Result<Vec<_>>>()?,
(None, None) => s.doc()?.selection.guides.clone(),
};
if moving.is_empty() {
return Err(bad(C, "no guide selected (or give an index)"));
}
s.edit("Move Guide to Layer", |d, _| {
for i in &moving {
if let Some(g) = d.guides.get_mut(*i) {
g.layer = Some(layer);
}
}
Ok(())
})?;
Ok(json!({ "count": moving.len() }))
}
/// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step.
pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("index") {
let i = guide_index(s, Some(v), "guide.move")?;
let i = editable_guide_index(s, Some(v), "guide.move")?;
let pos = f64_req(p, "pos", "guide.move")?;
s.edit("Move Guide", |d, _| {
if let Some(g) = d.guides.get_mut(i) {
+1 -12
View File
@@ -825,20 +825,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if seg >= sp.segment_count() {
if !sp.bend_segment(seg, t, dv) {
return Err(EngineError::Other("no such segment".into()));
}
let n = sp.anchors.len();
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
// smooth: its other handle turns with the moved one.
let v = dv / (3.0 * t * (1.0 - t));
let (i0, i1) = (seg % n, (seg + 1) % n);
if let Some(a) = sp.anchors.get_mut(i0) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = sp.anchors.get_mut(i1) {
b.set_handle(false, b.h_in + v, false);
}
Ok(())
})?;
ok()
+40
View File
@@ -39,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
"edit.undoGroup.begin",
"Begin Undo Group",
[],
None,
"{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)",
has_doc,
undo_group_begin
),
cmd!(
"edit.undoGroup.end",
"End Undo Group",
[],
None,
"{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone",
has_doc,
undo_group_end
),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} (the Artboard tool chosen: artboard.copy)", has_selection_or_artboard_tool, copy),
cmd!(
@@ -167,6 +185,22 @@ pub(crate) fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
}
fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.undoGroup.begin";
let label = match p.get("label") {
None | Some(Value::Null) => None,
Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()),
Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")),
};
s.begin_named_undo_group(label);
ok()
}
fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
s.end_undo_group(bool_or(p, "cancel", false));
ok()
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
@@ -268,6 +302,8 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
return super::docmenu::guide_remove(s, &json!({}));
}
let st = s.doc()?;
// Guides selected with the art go with it (not when ids are given).
let with_guides = explicit.is_none();
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
// Keep the selected ids themselves, without promoting compound members. Delete a selected
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
@@ -284,6 +320,10 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
d.remove(*id)?;
}
if with_guides {
let gone = sel.guides.clone();
d.retain_guides(sel, |i, _| !gone.contains(&i));
}
sel.clear();
Ok(())
})?;
+16 -3
View File
@@ -8,12 +8,14 @@ use crate::{DocState, EngineError};
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
// The active artboard the app passes, for the steps that act on it.
let artboard = p.get("artboard").and_then(Value::as_u64);
// What an error rolls back besides the documents: session-level state a step may change
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
s.batch_stash = Some(vec![]);
let r = run_steps(s, &label, &mut cmds);
let r = run_steps(s, &label, artboard, &mut cmds);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => results,
@@ -43,7 +45,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
/// Run the steps → their results. Each runs inside an interaction of the document active when it
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Value>> {
/// 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.
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 {
let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string();
@@ -53,7 +58,15 @@ fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Val
if s.active().is_some() {
s.begin_interaction(label)?;
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = c.get("params").cloned().unwrap_or(json!({}));
if let Some(a) = artboard
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
&& params.get("artboard").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))));
}
let (v, noted) =
s.execute_step(&id, &params).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
// The step's journal entry records what it took from the preferences or the clock (a
+9 -1
View File
@@ -123,7 +123,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
screens::export_for_screens
),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} 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!(
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",
has_doc,
batch::batch
),
cmd!(
query "document.formats",
"File Formats",
@@ -108,6 +108,15 @@ showpage
let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap();
assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind);
assert_eq!(inner, (2, Some(2)));
// One that names the prolog defining its operators without including it (as other apps write
// the format) opens with the layers its program has (#1027).
let named = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\
%%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(PLAN::Walls) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n";
let r = open(&mut s, "cad.ai", named).unwrap();
assert_eq!(r["warnings"].as_array().map_or(0, Vec::len), 0, "{r}");
let d = s.doc().unwrap().doc.clone();
assert_eq!((d.layers.len(), d.layers[0].name.as_deref(), objects(&s)), (1, Some("PLAN::Walls"), 1));
// A .ait one opens as a new untitled document.
let r = open(&mut s, "legacy.ait", ps).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}");
+28 -8
View File
@@ -148,9 +148,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String>
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
f.points.push(pt);
}
for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default();
f.add_line(ix)?;
if let Some(lines) = v.get("lines") {
let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?;
for (i, l) in lines.iter().enumerate() {
let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?;
f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?;
}
}
if let Some(m) = str_param(v, "mode") {
f.mode = parse_mode(m)?;
@@ -279,11 +282,7 @@ fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.addLine";
let points: Vec<usize> = p
.get("points")
.and_then(Value::as_array)
.and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect())
.ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
Ok(json!({ "line": line }))
}
@@ -323,3 +322,24 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
v["selected"] = json!(selected);
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
/// A line with an index that isn't a point's is refused, not shortened to the indices that are.
#[test]
fn malformed_freeform_lines_are_not_silently_shortened() {
let points = json!([
{"at": [0, 0], "color": "#ff0000"},
{"at": [50, 0], "color": "#00ff00"},
{"at": [100, 0], "color": "#0000ff"}
]);
for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] {
let value = json!({"points": points, "lines": lines, "mode": "lines"});
assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}");
}
let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap();
assert_eq!(good.lines, vec![vec![0, 1, 2]]);
}
}
+25 -15
View File
@@ -129,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
artboard_new
),
cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete),
cmd!(
"artboard.delete",
"Delete Artboard",
["Window", "Artboards"],
None,
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
has_doc,
artboard_delete
),
cmd!(
"artboard.setProps",
"Artboard Options…",
@@ -221,7 +229,7 @@ pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
}
/// Whether `id` or a container around it is locked.
fn locked_within(d: &Document, id: NodeId) -> bool {
pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
}
@@ -462,6 +470,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
d.remove(*id)?;
}
// The guides on the deleted layers go with them.
d.drop_guides_of_deleted_layers(sel);
sel.prune(d);
Ok(())
})?;
@@ -487,6 +497,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
let mut c = d.reid(&n);
if n.is_layer() {
c.name = Some(format!("{} copy", n.display_name()));
d.copy_layer_guides(&n, &c);
}
out.push(d.insert(par, idx + 1, c)?);
}
@@ -548,11 +559,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p);
}
// Hidden or locked content can't stay selected.
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
for h in hidden {
sel.remove(h);
}
sel.deselect_uneditable(d);
Ok(())
})?;
ok()
@@ -679,7 +686,11 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
nodes.push(if copy {
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
d.reid(&n)
let c = d.reid(&n);
if n.is_layer() {
d.copy_layer_guides(&n, &c);
}
c
} else {
Arc::unwrap_or_clone(d.remove(*id)?)
});
@@ -746,17 +757,16 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
s.edit("Delete Artboard", |d, sel| {
if d.artboards.len() <= 1 {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let id = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
// Its guides go with it.
let id = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(id));
Ok(())
})?;
ok()
+21 -19
View File
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Hide Others",
["Window", "Layers"],
None,
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer), which show. One undo step → {count}",
has_doc,
|s, p| others(s, p, Flag::Visible, false)
),
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Outline Others",
["Window", "Layers"],
None,
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer), which preview → {count}",
has_doc,
|s, p| others(s, p, Flag::Preview, false)
),
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Lock Others",
["Window", "Layers"],
None,
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer), which unlock → {count}",
has_doc,
|s, p| others(s, p, Flag::Locked, true)
),
@@ -176,27 +176,23 @@ impl Flag {
}
}
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
/// Set `flag` on each of `changes` (id, value; layers that differ) in one undo step labelled `label` → how many
/// changed.
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
fn set_flag(s: &mut Session, changes: Vec<(NodeId, bool)>, flag: Flag, label: &str) -> Result<Value> {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
if !ids.is_empty() {
let changes: Vec<(NodeId, bool)> = changes.into_iter().filter(|(id, v)| d.node(*id).is_some_and(|n| flag.get(n) != *v)).collect();
if !changes.is_empty() {
s.edit(label, |d, sel| {
for id in &ids {
for (id, v) in &changes {
if let Some(n) = d.node_mut(*id) {
flag.set(n, v);
flag.set(n, *v);
}
}
// Hidden or locked art can't stay selected.
let out: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
for o in out {
sel.remove(o);
}
sel.deselect_uneditable(d);
Ok(())
})?;
}
Ok(json!({ "count": ids.len() }))
Ok(json!({ "count": changes.len() }))
}
/// The top-level layers holding the rows a command acts on.
@@ -206,20 +202,22 @@ fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
}
/// Every top-level layer but the kept ones set to `v`, and the kept ones to the opposite: Alt-click
/// on a hidden layer's eye shows just that layer (#1158).
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
let keep = kept_layers(s, p)?;
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
set_flag(s, ids, flag, v, flag.label(v))
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
set_flag(s, changes, flag, flag.label(v))
}
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
let mut ids = vec![];
s.doc()?.doc.walk(|n| {
if n.is_layer() {
ids.push(n.id);
ids.push((n.id, v));
}
});
set_flag(s, ids, flag, v, flag.label(v))
set_flag(s, ids, flag, flag.label(v))
}
fn template(s: &mut Session, p: &Value) -> Result<Value> {
@@ -263,6 +261,8 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
all.extend(taken);
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
*ch = all;
// The merged layers' guides go to it too.
d.rehome_guides(into);
Ok(())
}
@@ -311,6 +311,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
for h in &hidden {
d.remove(*h)?;
}
// Hidden layers are discarded with their guides.
d.drop_guides_of_deleted_layers(sel);
merge_into(d, into, &others)?;
sel.prune(d);
Ok(())
+22 -1
View File
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point};
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
use super::edit::selected_roots;
use super::expand::gradient_sampler;
@@ -95,6 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
spine_move_anchor
),
cmd!(
"object.blend.spine.reshapeSegment",
"Reshape Spine Segment",
[],
None,
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
has_doc,
spine_reshape_segment
),
cmd!(
"object.blend.spine.addAnchor",
"Add Spine Point",
@@ -640,6 +649,18 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
ok()
}
fn spine_add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.addAnchor";
let q = finite_point(p, C)?;
+4 -3
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"All Artwork Above",
["Object", "Lock"],
None,
"{} lock objects in the same layer that are above and overlap the selection → {count}",
"{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too) → {count}",
has_selection,
|s, _| above(s, true)
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"All Artwork Above",
["Object", "Hide"],
None,
"{} hide objects in the same layer that are above and overlap the selection → {count}",
"{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too) → {count}",
has_selection,
|s, _| above(s, false)
),
@@ -228,7 +228,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
}
let label = if lock { "Lock" } else { "Hide" };
let n = hits.len();
s.edit(label, |d, _| {
s.edit(label, |d, sel| {
for id in &hits {
if let Some(n) = d.node_mut(*id) {
if lock {
@@ -238,6 +238,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
}
}
}
sel.deselect_uneditable(d);
Ok(())
})?;
Ok(json!({ "count": n }))
+11
View File
@@ -316,6 +316,11 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
values.iter().map(|value| checked_id(s, value, command)).collect()
}
/// The objects a command acts on: `ids` when given (every one checked), else the selected objects.
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) }
}
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
if s.doc()?.doc.node(id).is_none() {
@@ -335,6 +340,12 @@ pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
Some(out)
}
/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no
/// malformed index is dropped or wrapped round.
pub(crate) fn point_indices(v: &Value) -> Option<Vec<usize>> {
v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect()
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
+284 -78
View File
@@ -9,7 +9,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::corners::set_corners;
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
use super::opacitymask::percent;
@@ -106,7 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
cmd!(
"object.compoundPath.release",
"Release",
["Object", "Compound Path"],
Some("Cmd+Alt+Shift+8"),
"{} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath",
has_selection,
compound_release
),
cmd!(
"object.clippingMask.make",
"Make",
@@ -150,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
has_doc,
set_props
),
@@ -159,7 +167,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_selection,
align
),
@@ -168,7 +176,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
distribute
),
@@ -177,7 +185,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
distribute_spacing
),
@@ -196,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@@ -251,17 +259,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
let areas = bool_or(p, "typeAreas", false);
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
sc.patterns = transform_patterns(s, p, &ids)?;
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id)
&& !(areas && resize_type_area(n, xf))
{
n.transform(xf, sc);
// A transform of ids that name no object, or an identity transform without a copy, moves
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
// With a copy, the reply lists the copies, and there are none.
let d = &s.doc()?.doc;
let none = ids.iter().all(|id| d.node(*id).is_none());
let ids = if none || (!copy && xf == Affine::IDENTITY) {
if copy { vec![] } else { ids }
} else {
s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id)
&& !(areas && resize_type_area(n, xf))
{
n.transform(xf, sc);
}
}
}
Ok(targets)
})?;
Ok(targets)
})?
};
let st = s.doc_mut()?;
if st.interaction.is_none() {
st.last_transform = Some((xf, copy));
@@ -300,10 +317,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
if !s.doc()?.selection.anchors.is_empty() {
// When the anchors move by 0, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 {
return ok();
}
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
}
if s.doc()?.selection.is_empty() {
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) }));
let copy = bool_or(p, "copy", false);
// When guides move by 0 without a copy, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 && !copy {
return ok();
}
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
}
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
@@ -345,7 +371,12 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
let t = -n.as_f64().unwrap_or(90.0).to_radians();
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
}
_ => Affine::scale_non_uniform(-1.0, 1.0),
None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0),
Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0),
Some(v) => {
let given = v.as_str().map_or_else(|| v.to_string(), str::to_string);
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
}
};
apply_transform(s, "Reflect", ids, about(o, m), p)
}
@@ -354,8 +385,8 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
let m =
if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
apply_transform(s, "Shear", ids, about(o, m), p)
}
@@ -386,7 +417,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
Arrange::Backward => "Send Backward",
Arrange::Back => "Send to Back",
};
s.edit(label, |d, _| {
// When nothing changes order, the document and its undo history stay as they are.
super::overprint::edit_counted(s, label, |d| {
let mut moved = 0;
// Arrange within each parent independently (selections may span layers/groups).
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
for id in &ids {
@@ -411,10 +444,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
};
if to != idx {
d.move_node(*id, par, to)?;
moved += 1;
}
}
}
Ok(())
Ok(moved)
})?;
ok()
}
@@ -424,6 +458,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
// Ids are reused after undo: the remembered current layer must still be a layer.
let st = s.doc()?;
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
// When the selected objects are already the topmost in the current layer, in paint order, the
// document and its undo history stay as they are.
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
return ok();
}
s.edit("Send to Current Layer", |d, _| {
for id in &ids {
d.move_node(*id, Some(layer), usize::MAX)?;
@@ -457,6 +496,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no group selected, the document, the selection and the undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
return ok();
}
s.edit("Ungroup", |d, sel| {
let mut new_sel = vec![];
for id in &ids {
@@ -492,6 +536,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already locked, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Lock", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -516,6 +567,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
// With nothing locked, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Unlock All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -530,6 +585,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already hidden, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Hide", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -544,6 +606,10 @@ fn hide(s: &mut Session, _: &Value) -> Result<Value> {
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
// With nothing hidden, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Show All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -600,20 +666,52 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "id": id.0 }))
}
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
fn releases(n: &Node) -> bool {
match &n.kind {
NodeKind::Compound { .. } => true,
NodeKind::Path { path, .. } => path.subpaths.len() > 1,
_ => false,
}
}
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no compound path selected, the document, the selection and the undo history stay as
// they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) {
return ok();
}
s.edit("Release Compound Path", |d, sel| {
let mut out = vec![];
for id in &ids {
let Some(n) = d.node(*id).cloned() else { continue };
let NodeKind::Compound { children, .. } = &n.kind else { continue };
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
d.remove(*id)?;
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
out.push(c.id);
d.insert(par, idx + k, c)?;
match &n.kind {
NodeKind::Compound { children, .. } => {
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
out.push(c.id);
d.insert(par, idx + k, c)?;
}
}
// Each subpath becomes a path of its own with the path's paint, in the same
// stacking order; the first keeps the path's id.
NodeKind::Path { path, .. } => {
for (k, sp) in path.subpaths.iter().enumerate() {
let mut c = if k == 0 { n.clone() } else { d.reid(&n) };
if let NodeKind::Path { path, live, .. } = &mut c.kind {
(*path, *live) = (PathData::single(sp.clone()), None);
}
out.push(c.id);
d.insert(par, idx + k, c)?;
}
}
_ => {}
}
}
sel.set(out);
@@ -678,6 +776,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no clip group selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) {
return ok();
}
s.edit("Release Clipping Mask", |d, _| {
for id in &ids {
release_clip(d, *id);
@@ -785,7 +888,33 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
),
None => None,
};
s.edit("Object Properties", |d, _| {
let hides = p.get("visible").and_then(Value::as_bool) == Some(false) || p.get("locked").and_then(Value::as_bool) == Some(true);
// When every object already has every value given, the document and its undo history stay as
// they are.
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let has_values = |n: &Node| {
str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty()))
&& flag("visible").is_none_or(|v| n.visible == v)
&& flag("locked").is_none_or(|v| n.locked == v)
&& opacity.is_none_or(|v| n.opacity == v)
&& blend.is_none_or(|b| n.blend == b)
&& flag("isolate").is_none_or(|v| n.isolate == v)
&& knockout.is_none_or(|k| n.knockout == k)
&& flag("knockoutShape").is_none_or(|v| n.knockout_shape == v)
&& data
.iter()
.flatten()
.all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref())
};
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
// Hidden or locked objects can't stay selected.
if hides {
s.select(|d, sel| sel.deselect_uneditable(d))?;
}
return ok();
}
s.edit("Object Properties", |d, sel| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(v) = str_param(p, "name") {
@@ -831,6 +960,10 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
});
}
}
// Hidden or locked objects can't stay selected.
if hides {
sel.deselect_uneditable(d);
}
Ok(())
})?;
ok()
@@ -851,19 +984,51 @@ impl Session {
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
match str_param(p, "bounds") {
match choice(p, cmd, "bounds", &["preview", "geometric"])? {
None => Ok(s.prefs.use_preview_bounds),
Some("preview") => Ok(true),
Some("geometric") => Ok(false),
Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))),
Some(b) => Ok(b == "preview"),
}
}
/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or
/// null. Any other value is an error that lists `values`.
fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result<Option<&'a str>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
match v.as_str() {
Some(s) if values.contains(&s) => Ok(Some(s)),
_ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))),
}
}
/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`].
fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> {
Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?))
}
/// `ids` with their bounds (`preview`: visual bounds).
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// Move each object of `moves` by its offset, as undo step `label`; pattern tiles move too when
/// [`transform_patterns`] says so for `ids`. When every move is a rounding error (1e-9 pt or less
/// on both axes), the document and its undo history stay as they are.
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)]) -> Result<Value> {
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) {
return ok();
}
s.edit(label, |d, _| {
for (id, dv) in moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), sc);
}
}
Ok(())
})?;
ok()
}
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
/// selection.
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
@@ -875,8 +1040,12 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
match align_to(s, p) {
Some("artboard") => {
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
// it, or with an index the document doesn't have, it uses the artboard under the center
// of the selection, or the first one when no artboard is there.
let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i));
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
Ok(named.or(st.doc.artboards.get(i)).map(|a| a.rect).unwrap_or(b))
}
Some("key") => {
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
@@ -886,13 +1055,25 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
}
}
/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the
/// compound path or selected group the key is in. Align to the key object and Distribute Spacing
/// with a spacing keep it in place.
fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
let st = s.doc().ok()?;
let ancestry = st.doc.ancestry(st.selection.key?)?;
ids.iter().copied().find(|id| ancestry.contains(id))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.align")?;
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
let r = reference_rect(s, p, &ids, preview)?;
let h = str_param(p, "horizontal");
let v = str_param(p, "vertical");
let key = s.doc()?.selection.key;
let key = key_root(s, &ids);
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
items_bounds(d, &ids, preview)
@@ -915,22 +1096,14 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect()
};
s.edit("Align", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
apply_moves(s, "Align", p, &ids, &moves)
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) {
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
(Some(h), _) => (
true,
match h {
@@ -965,20 +1138,12 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
s.edit("Distribute", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
apply_moves(s, "Distribute", p, &ids, &moves)
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let horiz = str_param(p, "axis") != Some("vertical");
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
@@ -988,7 +1153,11 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite());
let spacing = p
.get("spacing")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
.transpose()?;
let gap = match spacing {
Some(g) => g,
None => {
@@ -1002,21 +1171,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
deltas.push((*id, pos - start(r)));
pos += size(r) + gap;
}
// With a spacing (aligning to a key object) the key object stays where it is: the others
// are spaced from it.
let key = s.doc()?.selection.key;
// With a spacing (aligning to a key object) the key object stays where it is, with the
// compound path or selected group it is in: the others are spaced from it.
let key = key_root(s, &ids);
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
apply_moves(s, "Distribute Spacing", p, &ids, &moves)
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1057,12 +1218,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
// x, y place the reference point on the page.
let page = frame.to_doc() * rp;
// When every value given is the current one, the transform is the identity. Computing it can
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
}
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no live shape selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
return ok();
}
s.edit("Expand Shape", |d, _| {
for id in &ids {
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
@@ -1138,6 +1310,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
// ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
let pie_of = |pie: (f64, f64)| {
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
};
// When every value given is the current one, the document and its undo history stay as they
// are.
let st = s.doc()?;
let has_values = |id: &NodeId| {
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
match live {
Some(l @ LiveShape::Polygon { sides: now, .. })
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
&& (!equal || l.polygon_sides_equal())
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
{
return false;
}
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
_ => {}
}
if radius.is_none() && kind.is_none() {
return true;
}
let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true };
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
// Setting corners folds a scale left in a rectangle's transform into its size, which
// changes it.
live.as_ref().is_none_or(|l| l.folded() == *l)
&& picked
.iter()
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
};
if ids.iter().all(has_values) {
return ok();
}
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -1163,12 +1374,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert {
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
}
*pie = pie_of(*pie);
}
*path = l.to_path();
}
+49 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -375,10 +375,41 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
if p.get("ids").is_none()
&& p.get("item").is_none()
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
&& a != b
{
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let (xf, lb) = (t.xf, t.local_bounds());
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
if fill {
st.fill = place_run_paint(&paint, p, xf, lb);
} else {
if !paint.is_none() {
run_stroke_weight(st);
}
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
}
});
super::typecmd::refresh_bounds(t);
Ok(())
})?;
if bool_or(p, "focus", true) {
s.fill_active = fill;
}
s.remember_paint(&paint);
return ok();
}
// Resolve the requested targets before mutating defaults or the active proxy.
let item = item_target(s, p, cmd)?.of_kind(s, fill);
let selected = item.targets(s, p)?;
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
// recolours just that point of its mesh.
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
let label = if fill { "Fill Color" } else { "Stroke Color" };
edit_items_then(
s,
@@ -388,6 +419,15 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
label,
fill,
|n, index| {
if index.is_none()
&& let Paint::Solid { color, .. } = &paint
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
&& let NodeKind::Mesh(m) = &mut n.kind
&& let Some(q) = m.points.get_mut(i)
{
q.color = *color;
return Ok(());
}
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
@@ -429,6 +469,14 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
ok()
}
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
/// Selection's point last clicked).
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
let o = s.tool_options();
let pt = o.get("meshPoint")?;
Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?))
}
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
if !paint.is_none() {
+27 -13
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -78,6 +78,21 @@ fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
/// in order and once each.
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let listed = match (p.get("indices"), p.get("index")) {
(Some(v), _) => point_indices(v),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(cmd, "missing index")),
};
let mut indices =
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
indices.sort_unstable();
indices.dedup();
Ok(indices)
}
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
@@ -185,14 +200,7 @@ const MAX_PARA: f64 = 1296.0;
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
};
if ids.is_empty() {
return Err(bad(C, "no text objects selected"));
}
let ids = super::typecmd::text_targets(s, p, C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let kerning = match p.get("kerning") {
@@ -222,6 +230,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"direction",
"leadingModel",
"charAlign",
"proportionalMetrics",
"burasagari",
"composer",
];
@@ -285,15 +294,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
super::typecmd::style_chars(t, range, |st| {
if let Some(k) = kerning {
st.kerning = k;
}
if let Some(v) = num("baselineShift") {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
if let Some(v) = baseline_shift {
st.baseline_shift = v;
}
if let Some(v) = num("hScale") {
st.h_scale = v.clamp(1.0, 10000.0);
if let Some(v) = h_scale {
st.h_scale = v;
}
if let Some(v) = num("vScale") {
st.v_scale = v.clamp(1.0, 10000.0);
@@ -319,6 +330,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align {
st.char_align = v;
}
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
});
let span = super::typecmd::para_span(range, t);
t.edit_paras(span, |para| {
+7 -2
View File
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
@@ -216,7 +216,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
let layers = std::mem::take(&mut doc.layers);
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None)
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
// default, as Illustrator places one, it is its art's bounds.
let framed = matches!(format.id, "svg" | "svgz") && crop && p.get("crop").is_some();
let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed);
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), frame)
} else {
// The page placed is a PDF import's only one.
let index = if pdf { 0 } else { page - 1 };
+5
View File
@@ -87,6 +87,10 @@ const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
/// `automatic` (#502).
pub const GPU_PREFERENCES: &[(&str, &str)] =
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
/// (winit 0.30 has no Wayland drag and drop, #1151).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
@@ -290,6 +294,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)),
// File Handling
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
+43 -22
View File
@@ -26,6 +26,9 @@ use vectorcraft_render::{AntiAlias, RenderOptions};
use super::*;
mod report;
use report::FlattenReport;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"document.rasterEffectsSettings",
@@ -409,9 +412,9 @@ fn needs(n: &Node, which: Which) -> bool {
}
/// The art of `n`'s opacity mask with its raster effects as images.
fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) {
if let Some(m) = n.mask.as_mut()
&& let Some(art) = walk(out, &m.art, which)
&& let Some(art) = walk(out, &m.art, report)
{
m.art = Arc::new(art);
}
@@ -420,15 +423,23 @@ fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it
/// has none. An object whose effects all paint below it keeps its vector art over an image of
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
let checkpoint = report.checkpoint();
let result = walk_inner(out, n, report);
report.attribute_since(checkpoint, n);
result
}
fn walk_inner(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
let which = report.which;
if !needs(n, which) {
return None;
}
if !imaged(n, which) {
let mut m = n.clone();
walk_mask(out, &mut m, which);
walk_mask(out, &mut m, report);
if is_container(&m)
&& let Some(ch) = walk_all(out, n.children()?, which)
&& let Some(ch) = walk_all(out, n.children()?, report)
&& let Some(slot) = m.children_mut()
{
*slot = ch;
@@ -436,33 +447,41 @@ fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
return Some(m);
}
if let Some(g) = placed_symbol(out, n) {
return Some(walk(out, &g, which).unwrap_or(g));
return Some(walk(out, &g, report).unwrap_or(g));
}
let below = below_only(n);
if !below && let Some(g) = split_items(out, n) {
return Some(walk(out, &g, which).unwrap_or(g));
return Some(walk(out, &g, report).unwrap_or(g));
}
// An image that can't be made leaves the object as it is (the writer reports its effects).
let mut v = n.clone();
strip_raster(&mut v, false);
let mut m = if below {
let images = below_images(out, n)?;
let mut m = walk(out, &v, which).unwrap_or(v);
for image in &images {
report.record(n, image);
}
let mut m = walk(out, &v, report).unwrap_or(v);
// Each goes in first: the last one put is the lowest.
for image in images.into_iter().rev() {
put_below(out, &mut m, image);
}
m
} else {
replace_with_image(v, raster_image(out, n, false, false)?)
let image = raster_image(out, n, false, false)?;
report.record(n, &image);
replace_with_image(v, image)
};
walk_mask(out, &mut m, which);
walk_mask(out, &mut m, report);
Some(m)
}
/// `nodes` with their raster effects as images, `None` when none has any.
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec<Arc<Node>>> {
nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], report: &mut FlattenReport) -> Option<Vec<Arc<Node>>> {
nodes
.iter()
.any(|n| needs(n, report.which))
.then(|| nodes.iter().map(|n| walk(out, n, report).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
}
/// A copy of `doc` with raster effects turned into images at the document's raster effects
@@ -470,17 +489,18 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
/// opacity masks, symbol definitions and pattern tiles.
pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
flatten(doc, Which::All)
flatten(doc, &mut FlattenReport::new(Which::All, doc))
}
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
/// other raster effects live), or `None` when there are none.
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
flatten(doc, Which::Pixel)
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
}
fn flatten(doc: &Document, which: Which) -> Option<Document> {
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
let which = report.which;
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
return None;
@@ -489,18 +509,18 @@ fn flatten(doc: &Document, which: Which) -> Option<Document> {
let baked = effects::bake_document(doc);
let src = baked.as_ref().unwrap_or(doc);
let mut out = src.clone();
if let Some(layers) = walk_all(&mut out, &src.layers, which) {
if let Some(layers) = walk_all(&mut out, &src.layers, report) {
out.layers = layers;
}
for i in 0..out.symbols.len() {
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which))
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, report))
&& let Some(s) = out.symbols.get_mut(i)
{
s.art = Arc::new(art);
}
}
for i in 0..out.patterns.len() {
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which))
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, report))
&& let Some(p) = out.patterns.get_mut(i)
{
p.art = art;
@@ -520,10 +540,11 @@ pub fn export_pdf_with_report(
doc: &Document,
opts: &vectorcraft_pdf::PdfOptions,
) -> std::result::Result<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
match flatten_raster_effects(doc) {
Some(d) => vectorcraft_pdf::export_with_report(&d, opts),
None => vectorcraft_pdf::export_with_report(doc, opts),
}
let mut report = FlattenReport::new(Which::All, doc);
let flat = flatten(doc, &mut report);
let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?;
exported.warnings.extend(report.warnings());
Ok(exported)
}
#[cfg(test)]
+129
View File
@@ -0,0 +1,129 @@
//! The PDF export warnings for raster effects whose images the 64 million pixel cap rendered below
//! the document's raster effects resolution, one per object of the document.
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::Which;
pub(super) struct FlattenReport {
pub which: Which,
requested_ppi: f64,
reductions: Vec<Reduction>,
/// Ids from here on are the flattening's own (split appearance items, groups under shadows).
first_new_id: u64,
}
struct Reduction {
id: NodeId,
name: Option<String>,
achieved_ppi: f64,
}
impl FlattenReport {
pub fn new(which: Which, doc: &Document) -> Self {
Self { which, requested_ppi: super::effects_scale(doc) * 72.0, reductions: vec![], first_new_id: doc.peek_next_id() }
}
/// Note `image` (made for the raster effects of `original`) when the cap lowered its resolution.
pub fn record(&mut self, original: &Node, image: &Node) {
if let NodeKind::Image(image) = &image.kind {
let [pixel_size, ..] = image.xf.as_coeffs();
let achieved_ppi = 72.0 / pixel_size;
if achieved_ppi + 1e-6 < self.requested_ppi {
self.reductions.push(Reduction { id: original.id, name: original.name.clone(), achieved_ppi });
}
}
}
pub fn checkpoint(&self) -> usize {
self.reductions.len()
}
/// Give the reductions recorded since `checkpoint` on pieces the flattening made to `original`,
/// a document object (the innermost one, as the walk returns from it).
pub fn attribute_since(&mut self, checkpoint: usize, original: &Node) {
let first_new_id = self.first_new_id;
if original.id.0 >= first_new_id {
return;
}
for reduction in self.reductions.iter_mut().skip(checkpoint) {
if reduction.id.0 >= first_new_id {
reduction.id = original.id;
reduction.name.clone_from(&original.name);
}
}
}
pub fn warnings(self) -> Vec<String> {
let mut lowest: Vec<Reduction> = vec![];
for reduction in self.reductions {
if let Some(existing) = lowest.iter_mut().find(|r| r.id == reduction.id) {
existing.achieved_ppi = existing.achieved_ppi.min(reduction.achieved_ppi);
} else {
lowest.push(reduction);
}
}
lowest
.into_iter()
.map(|r| {
let name = r.name.map(|name| format!(" ({name})")).unwrap_or_default();
format!(
"raster effects on object {}{name}: requested {:.1} ppi, achieved {:.1} ppi due to the 64 million pixel cap",
r.id.0, self.requested_ppi, r.achieved_ppi
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::ImageObject;
use vectorcraft_geom::Affine;
fn image(ppi: f64) -> Node {
Node::new(
NodeId(999),
NodeKind::Image(ImageObject {
key: "effect".into(),
width: 8000,
height: 8000,
xf: Affine::scale(72.0 / ppi),
link: None,
placement: Default::default(),
}),
)
}
#[test]
fn lowest_resolution_is_reported_when_multiple_images_belong_to_one_object() {
let mut doc = Document::new(100.0, 100.0);
doc.raster_effects_ppi = 2400.0;
let original = &doc.layers[0];
let mut report = FlattenReport::new(Which::All, &doc);
for ppi in [1800.0, 1200.0, 1500.0] {
report.record(original, &image(ppi));
}
let warnings = report.warnings();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("achieved 1200.0 ppi"), "{:?}", warnings);
}
#[test]
fn parent_identity_is_reported_when_generated_pieces_are_attributed() {
let doc = Document::new(100.0, 100.0);
let original = &doc.layers[0];
let generated = Node::group(NodeId(998), vec![]);
let mut report = FlattenReport::new(Which::All, &doc);
report.record(&generated, &image(36.0));
report.attribute_since(0, original);
let warnings = report.warnings();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains(&format!("object {}", original.id.0)), "{:?}", warnings);
}
}
+10 -2
View File
@@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
all
),
cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard),
cmd!(
"select.allOnArtboard",
"All on Active Artboard",
["Select"],
Some("Cmd+Alt+A"),
"{artboard?: index (0-based; the app passes the active one; default: the first)}",
has_doc,
all_on_artboard
),
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
@@ -57,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Key Object",
[],
None,
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
"{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)",
has_doc,
key
),
+1 -1
View File
@@ -500,7 +500,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
let expand = supplied.is_none() && st.selection.objects.len() < 2;
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
let eligible: Vec<NodeId> = ids
+2 -2
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Swatch Library…",
["Window", "Swatches"],
None,
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count: the swatches written (gpl and ase hold solid colors only, and css leaves freeform gradients out), library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
has_doc,
save
),
@@ -454,7 +454,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
let mut out = json!({"format": format.id(), "count": lib.len()});
let mut out = json!({"format": format.id(), "count": lib.iter().filter(|w| format.holds(&w.paint)).count()});
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
Ok(out)
}
+51 -16
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Get Text Range",
[],
None,
"{id, start?, end?} → {text, runs: [{text, style}], length}",
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
has_doc,
get_range
),
@@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let runs = edit::slice_runs(&t.runs, a, b);
let mut runs = edit::slice_runs(&t.runs, a, b);
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
for run in &mut runs {
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
}
}
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
Ok(json!({"text": text, "runs": runs, "length": len}))
}
@@ -203,6 +208,7 @@ pub(crate) struct CharChange {
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
proportional_metrics: Option<bool>,
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
@@ -286,6 +292,7 @@ impl CharChange {
position,
small_caps,
char_align: char_align_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -314,6 +321,16 @@ impl CharChange {
&& self.position.is_none()
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.proportional_metrics.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
use super::typecmd::local_type_value;
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
Ok(())
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
@@ -338,10 +355,10 @@ impl CharChange {
st.font_version = None;
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(v) = self.leading {
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
st.leading = v;
}
if let Some(v) = self.tracking {
st.tracking = v.clamp(-1000.0, 10000.0);
@@ -350,10 +367,10 @@ impl CharChange {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.baseline_shift {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
st.baseline_shift = v;
}
if let Some(v) = self.h_scale {
st.h_scale = v.clamp(1.0, 10000.0);
st.h_scale = v;
}
if let Some(v) = self.v_scale {
st.v_scale = v.clamp(1.0, 10000.0);
@@ -391,6 +408,9 @@ impl CharChange {
if let Some(v) = self.char_align {
st.char_align = v;
}
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
}
}
@@ -445,12 +465,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
},
};
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
let t = text_ref(s, id)?;
change.localize(&t, C)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs));
let n = s.edit("Character", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
@@ -542,7 +563,7 @@ enum Step {
}
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
let o = s.tool_options();
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
if super::edit::typing_in_progress(s) {
@@ -605,13 +626,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for &(id, a, b) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| match step {
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
});
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
} else {
1.0
};
let mut runs = edit::slice_runs(&t.runs, a, b);
for run in &mut runs {
let st = &mut run.style;
match step {
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
Step::Leading => {
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
}
}
edit::replace_range_styled(&mut t.runs, a, b, &runs);
refresh_bounds(t);
}
Ok(())
+32 -11
View File
@@ -8,7 +8,6 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
use vectorcraft_geom::{PathData, shapes};
use super::edit::selected_roots;
use super::typecmd::refresh_bounds;
use super::*;
@@ -36,19 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match p.get("ids").and_then(Value::as_array) {
Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
None => selected_roots(s),
}
}
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?;
let w = wrap_param(p, TextWrap::default());
s.edit("Make Text Wrap", |d, _| {
for id in &ids {
@@ -66,7 +58,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = {
let d = &s.doc()?.doc;
targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
};
if ids.is_empty() {
return Err(EngineError::Other("Text Wrap: select a wrap object".into()));
@@ -83,7 +75,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?;
let current = {
let d = &s.doc()?.doc;
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default()
@@ -212,6 +204,35 @@ mod tests {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect()
}
/// Invalid explicit ids must not turn a requested multi-object edit into a partial one.
#[test]
fn text_wrap_commands_reject_malformed_target_lists_without_editing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let before = s.doc().unwrap().history.undo.len();
for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] {
for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] {
let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string();
assert!(e.contains("id") || e.contains("array"), "{command}: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error");
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
}
}
s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some());
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0);
s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
}
#[test]
fn area_type_wraps_around_objects_above_and_follows_edits() {
let mut s = Session::new();
+48 -2
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
has_doc,
set_style
),
@@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
Ok(t)
}
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
/// the local result would make small sizes impossible after enlarging an object.
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
if !value.is_finite() {
return Err(bad(cmd, "type dimension must be finite"));
}
let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?;
let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points };
// Bound local dimensions too: an extreme inverse scale can produce a finite size
// whose leading, glyph advances or layout bounds overflow before the edit's geometry check.
if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) {
return Err(bad(cmd, "type dimension cannot be represented with this text transform"));
}
Ok(local)
}
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
/// paragraphs it touches. Without either, the command applies to all the text.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?;
let before = protect.then(|| t.runs.clone());
style_chars(t, range, |st| {
if let Some(f) = &font {
@@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
st.font_version = None;
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(l) = leading {
st.leading = l;
@@ -841,6 +859,34 @@ mod area_tests {
}
}
/// Proportional Metrics (#966): a character attribute, saved only when on.
#[test]
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
assert!(!runs(&s)[0].style.proportional_metrics);
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.proportional_metrics));
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!runs(&s)[0].style.proportional_metrics);
// Characters selected with the Type tool: only the range takes it (雅 is 3 bytes).
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap();
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect();
assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]);
// Styles written without it (documents from before it) read as off.
let old = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(old.get("proportionalMetrics").is_none());
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
}
#[test]
fn new_type_is_composed_with_line_end_half_width_punctuation() {
use vectorcraft_doc::Mojikumi;
+2 -7
View File
@@ -49,11 +49,6 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
}
}
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
}
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
/// layer is not the document's to rewrite, and a text variable only drives type.
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
@@ -203,7 +198,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.bind";
let name = named(str_param(p, "variable"), "variable", C)?;
let ids = target_ids(s, p, C)?;
let ids = checked_ids_or_selection(s, p, C)?;
if ids.is_empty() {
return Err(bad(C, "nothing to bind"));
}
@@ -218,7 +213,7 @@ fn bind(s: &mut Session, p: &Value) -> Result<Value> {
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.unbind";
let ids = target_ids(s, p, C)?;
let ids = checked_ids_or_selection(s, p, C)?;
let name = defined_variable(s, p, C)?;
let n = s.edit("Unbind Variable", |d, _| {
let mut n = 0;
+30 -3
View File
@@ -55,12 +55,16 @@ fn list_views(s: &mut Session, _: &Value) -> Result<Value> {
fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.saved.new";
let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?;
let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?;
let zoom = f64_req(p, "zoom", C)?;
if !(zoom.is_finite() && zoom > 0.0) {
return Err(bad(C, "zoom must be positive"));
}
// Given, the rotation is a number: nothing malformed falls back to 0°.
let rotation = match p.get("rotation") {
Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?,
None => 0.0,
};
let st = s.doc()?;
if st.doc.views.len() >= MAX_VIEWS {
return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views")));
@@ -72,7 +76,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.views.iter().any(|v| v.name == name) {
return Err(bad(C, format!("a view named `{name}` exists")));
}
let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) };
let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation };
let index = s.edit("New View", |d, _| {
d.views.push(view);
Ok(d.views.len() - 1)
@@ -108,6 +112,29 @@ mod tests {
use crate::Session;
/// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport.
#[test]
fn saved_views_require_exact_finite_coordinates_and_rotation() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let before = s.doc().unwrap().history.undo.len();
for params in [
json!({"center": [10], "zoom": 2}),
json!({"center": [10, 20, 30], "zoom": 2}),
json!({"center": [10, "bad"], "zoom": 2}),
json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}),
json!({"center": [10, 20], "zoom": 2, "rotation": null}),
] {
assert!(s.execute("view.saved.new", &params).is_err(), "accepted {params}");
assert!(s.doc().unwrap().doc.views.is_empty());
assert_eq!(s.doc().unwrap().history.undo.len(), before);
}
s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap();
let views = &s.doc().unwrap().doc.views;
assert_eq!(views.len(), 1);
assert_eq!(views[0].rotation, -15.0);
}
#[test]
fn save_rename_delete_views() {
let mut s = Session::new();
+31 -16
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Artboard",
[],
None,
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the art fully inside it; locked and hidden art only with prefs moveLockedWithArtboard); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
has_doc,
artboard_move
),
@@ -366,26 +366,41 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- artboards ----------
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let art = super::panelcmds::artboard_art(s, i, bool_or(p, "moveArt", false))?;
let move_art = bool_or(p, "moveArt", false);
// Each artboard's art, an object counted once (artboards can overlap).
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
for &i in &indices {
let art = super::panelcmds::artboard_art(s, i, move_art)?;
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
arts.push((i, art));
}
let scale_strokes = s.prefs.scale_strokes;
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if copy {
return super::panelcmds::copy_artboard(d, i, dv, &art, scale_strokes);
}
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
let id = a.id;
d.move_artboard_guides(id, dv);
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
let (mut index, mut moved) = (vec![], vec![]);
for (i, art) in &arts {
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if copy {
let (at, copies) = super::panelcmds::copy_artboard(d, *i, dv, art, scale_strokes)?;
index.push(at);
moved.extend(copies);
continue;
}
let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
let id = a.id;
d.move_artboard_guides(id, dv);
for id in art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
index.push(*i);
moved.extend(art.iter().copied());
}
Ok((i, art.clone()))
Ok((index, moved))
})?;
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(json!({ "index": index.first(), "indices": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+55 -5
View File
@@ -100,6 +100,8 @@ pub struct UndoGroup {
first: Option<Arc<Document>>,
/// The journal's length when the group began: a cancelled group drops the entries after it.
journal: usize,
/// The undo step's name; else the first edit's.
label: Option<String>,
}
/// Per-document editing state.
@@ -285,13 +287,16 @@ impl DocState {
}
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
/// edit records one: the edits after it extend that step.
fn push_undo(&mut self, e: HistoryEntry) {
fn push_undo(&mut self, mut e: HistoryEntry) {
if let Some(g) = &mut self.undo_group {
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
if g.first.as_ref().is_some_and(recorded) {
return;
}
g.first = Some(e.doc.clone());
if let Some(l) = &g.label {
e.label = l.clone();
}
}
self.history.undo.push(e);
if self.history.undo.len() > self.history.limit {
@@ -369,10 +374,27 @@ impl DocState {
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
pub const MAX_COORD: f64 = 4.0e6;
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, and All on
/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their
/// steps that names none.
pub const ON_ACTIVE_ARTBOARD: [&str; 5] = ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack", "object.align", "select.allOnArtboard"];
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`); an action leaves them out
/// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the
/// [`ON_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves them out
/// ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")];
const REPLAY_ONLY: [(&str, &str); 9] = [
("file.new", "created"),
("document.save", "modified"),
("file.saveAs", "modified"),
("edit.pasteInPlace", "artboard"),
("edit.pasteInFront", "artboard"),
("edit.pasteInBack", "artboard"),
("object.align", "artboard"),
("select.allOnArtboard", "artboard"),
("command.batch", "artboard"),
];
/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps').
fn strip_replay_only(id: &str, p: &mut Value) {
@@ -547,6 +569,9 @@ pub struct Prefs {
pub real_time_drawing: bool,
/// Rasterizer worker threads; -1 = automatic.
pub render_threads: i32,
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
pub linux_display_server: String,
// File Handling
pub background_save: bool,
pub background_export: bool,
@@ -752,6 +777,7 @@ impl Default for Prefs {
history_states: 500,
real_time_drawing: true,
render_threads: -1,
linux_display_server: s("automatic"),
background_save: true,
background_export: true,
autosave_recovery: true,
@@ -1041,6 +1067,8 @@ impl Session {
/// Add a document and make it active.
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
Self::refresh_text_bounds(&mut doc);
// Guides from files saved before guides had layers go on a layer.
doc.adopt_guides();
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
@@ -1056,6 +1084,7 @@ impl Session {
return false;
}
Self::refresh_text_bounds(&mut doc);
doc.adopt_guides();
if self.active == Some(index) {
// Pending tool work (typing, a drag) belongs to the content being thrown away.
self.reset_tool_for_doc_switch();
@@ -1321,6 +1350,11 @@ impl Session {
{
let st = self.doc_mut()?;
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
// Undoing the previous preview counts as a change, so views redraw even when the command
// below returns without an edit.
if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection {
st.revision += 1;
}
st.doc = it.doc.clone();
st.selection = it.selection.clone();
}
@@ -1341,10 +1375,17 @@ impl Session {
let Some(mut it) = st.interaction.take() else { return Ok(()) };
let Some(preview) = it.preview.take() else { return Ok(()) };
let perspective_again = it.perspective_again.take();
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
// Alt pressed or released during a move drag turns it into a copy or back: name it for
// what it did in the end.
match (it.label.as_str(), copy) {
("Move", true) => it.label = "Copy".into(),
("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(),
_ => {}
}
st.keep_interaction(it);
if preview.0 == "object.transform" {
let m = cmd::matrix_param(&preview.1, "matrix");
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
if let Some(m) = m {
st.last_transform = Some((m, copy));
st.last_perspective = None;
@@ -1381,11 +1422,16 @@ impl Session {
/// applies each value it passes as its own command, as a typed value is applied; the drag is
/// one step.
pub fn begin_undo_group(&mut self) {
self.begin_named_undo_group(None);
}
/// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit).
pub fn begin_named_undo_group(&mut self, label: Option<String>) {
let journal = self.journal.len();
if let Some(st) = self.active_mut()
&& st.undo_group.is_none()
{
st.undo_group = Some(UndoGroup { first: None, journal });
st.undo_group = Some(UndoGroup { first: None, journal, label });
}
}
@@ -1629,6 +1675,8 @@ mod tests_recolor;
#[cfg(test)]
mod tests_recovery;
#[cfg(test)]
mod tests_reflecthit;
#[cfg(test)]
mod tests_registration;
#[cfg(test)]
mod tests_save;
@@ -1681,6 +1729,8 @@ mod tests_transparencygrid;
#[cfg(test)]
mod tests_typearea;
#[cfg(test)]
mod tests_typescale;
#[cfg(test)]
mod tests_units;
#[cfg(test)]
mod tests_variables;
+647
View File
@@ -52,6 +52,67 @@ fn group_ungroup() {
assert_eq!(s.doc().unwrap().selection.len(), 2);
}
/// Makes 10 pt squares at (0, 90) and (50, 100) into a compound path or group with the commands in
/// `make`, each run on the selection (the last one makes `outer`), draws a 10 pt square `r` at
/// (200, 0), selects the second square and `r`, and sets the selection's `key` to that square
/// → (session, key, outer, r).
fn key_inside(make: &[&str]) -> (Session, NodeId, NodeId, NodeId) {
let mut s = session();
let a = rect(&mut s, 0.0, 90.0, 10.0, 10.0);
let key = rect(&mut s, 50.0, 100.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, key.0]})).unwrap();
let mut outer = key;
for cmd in make {
outer = NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap());
}
let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [key.0, r.0]})).unwrap();
s.select(|_, sel| sel.key = Some(key)).unwrap();
(s, key, outer, r)
}
/// Aligns the selection left to its key object and, after Undo, spaces it 5 pt apart vertically
/// → the top-left corners of `outer` and `r` after each.
fn aligned_then_spaced(s: &mut Session, outer: NodeId, r: NodeId) -> [((f64, f64), (f64, f64)); 2] {
let at = |s: &Session, id| {
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
(b.x0, b.y0)
};
s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap();
let aligned = (at(s, outer), at(s, r));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5, "bounds": "geometric"})).unwrap();
[aligned, (at(s, outer), at(s, r))]
}
/// Align and Distribute Spacing move a compound path as one object. With one of its members as the
/// key object, the compound path stays where it is: Align moves the others to the member's edge,
/// and Distribute Spacing spaces them from the compound path.
#[test]
fn a_compound_path_that_contains_the_key_object_stays_in_place() {
let (mut s, key, outer, r) = key_inside(&["object.compoundPath.make"]);
assert_eq!(s.doc().unwrap().selection.key, Some(key));
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "it stays, the other moved to the key");
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "5 pt apart, it stays");
}
/// The key object stays the key when the group that contains it is added to the selection, by
/// `select.add` or by the Group Selection tool's click on a selected member. Align and Distribute
/// Spacing move that group as one object, and the group stays where it is, also when the key is in
/// a group inside it.
#[test]
fn a_selected_group_that_contains_the_key_object_stays_in_place() {
for make in [&["object.group"][..], &["object.group", "object.group"]] {
let (mut s, key, outer, r) = key_inside(make);
s.execute("select.add", &json!({"ids": [outer.0]})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}");
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "{make:?}: it stays, the other moved to the key");
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "{make:?}: 5 pt apart, it stays");
}
}
#[test]
fn arrange_order() {
let mut s = session();
@@ -250,6 +311,57 @@ fn layers_and_lock_hide() {
assert!(s.doc().unwrap().doc.node(b).is_none());
}
/// A key object is one object of a selection of several: a Shift-click (`select.toggle`) or a
/// hidden or deleted row that leaves one object selected clears the key, and `select.key` sets
/// none on a single selected object. A Shift-drag marquee that leaves two or more objects selected
/// keeps the key in either order of its ids. `select.key` also sets none on a group selected with
/// only its own member, and sets it once another object is selected.
#[test]
fn a_lone_selected_object_is_not_the_key_object() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0);
let c = rect(&mut s, 200.0, 60.0, 10.0, 10.0);
let key = |s: &Session| s.doc().unwrap().selection.key;
let objects = |s: &Session| s.doc().unwrap().selection.objects.clone();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!(key(&s), Some(b));
s.execute("select.toggle", &json!({"id": a.0})).unwrap();
assert_eq!(objects(&s), vec![b]);
assert_eq!(key(&s), None, "the Shift-click left one object");
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!(key(&s), None, "one object selected");
for ids in [[a.0, c.0], [c.0, a.0]] {
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
s.execute("select.toggle", &json!({"ids": ids})).unwrap();
assert_eq!((objects(&s), key(&s)), (vec![b, c], Some(b)), "the marquee toggled {ids:?}");
}
// The Group Selection tool's second click on a member adds its group.
s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
s.execute("select.add", &json!({"ids": [g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), None, "a group and its own member");
s.execute("select.add", &json!({"ids": [a.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g), "another object outside the group");
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g));
s.execute("layer.setProps", &json!({"id": a.0, "visible": false})).unwrap();
assert_eq!((objects(&s), key(&s)), (vec![g], None), "hiding the other object's row left one object");
s.execute("layer.setProps", &json!({"id": a.0, "visible": true})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g));
s.execute("layer.delete", &json!({"id": a.0})).unwrap();
assert_eq!(objects(&s), vec![g]);
assert_eq!(key(&s), None, "deleting the other object's row left one object");
}
#[test]
fn clipping_and_compound() {
let mut s = session();
@@ -269,6 +381,90 @@ fn clipping_and_compound() {
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
}
/// #1163: a path of several subpaths (an SVG `d` with several `M`s) is a compound path to Release:
/// one path per subpath, each with the path's paint, in its stacking order, in one undo step.
#[test]
fn release_splits_a_path_of_several_subpaths() {
let mut s = session();
let id = NodeId(
s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M20 0 L30 0 L30 10 Z M40 0 L50 0 L50 10 Z"})).unwrap()["id"].as_u64().unwrap(),
);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.compoundPath.release", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
let sel = st.selection.objects.clone();
assert_eq!((sel.len(), sel[0]), (3, id), "the first keeps its id");
let layer = st.doc.layers[0].children().unwrap();
for (k, n) in sel.iter().map(|i| st.doc.node(*i).unwrap()).enumerate() {
let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") };
assert_eq!(path.subpaths.len(), 1);
assert_eq!(path.bounds().unwrap().x0, 20.0 * k as f64, "in order");
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000", "the path's paint");
assert_eq!(layer[k].id, n.id, "stacked bottom to top");
}
}
/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as
/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were.
#[test]
fn align_without_a_direction_is_refused() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let stacks = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
let before = stacks(&s);
for p in [json!({}), json!({"align": "left"}), json!({"to": "artboard"}), json!({"horizontal": null, "vertical": null})] {
let e = s.execute("object.align", &p).expect_err(&p.to_string());
assert_eq!(e.to_string(), "invalid parameters for `object.align`: give horizontal or vertical", "{p}");
}
assert_eq!(stacks(&s), before, "no undo step, and the redo step is kept");
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved");
}
/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param
/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as
/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally.
#[test]
fn align_and_distribute_reject_bad_params_without_changing_state() {
let mut s = session();
let ids: Vec<NodeId> = [0.0, 50.0, 200.0].into_iter().map(|x| rect(&mut s, x, x / 2.0, 10.0, 10.0)).collect();
s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()})).unwrap();
let bounds = |s: &Session| ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().geometric_bounds().unwrap()).collect::<Vec<_>>();
let before = bounds(&s);
let undo = s.doc().unwrap().history.undo.len();
let h = "`horizontal` must be one of left, center, right";
let v = "`vertical` must be one of top, center, bottom";
let to = "`to` must be one of selection, artboard, key";
for (cmd, p, msg) in [
("object.align", json!({"horizontal": "middle"}), h),
("object.align", json!({"vertical": "centre"}), v),
("object.align", json!({"horizontal": true}), h),
("object.align", json!({"horizontal": "left", "to": "page"}), to),
("object.align", json!({"horizontal": "left", "to": 0}), to),
("object.align", json!({"horizontal": "left", "bounds": false}), "`bounds` must be one of preview, geometric"),
("object.align", json!({"horizontal": "left", "vertical": "middle"}), v),
("object.distribute", json!({"horizontal": "middle"}), h),
("object.distribute", json!({"vertical": "centre"}), v),
("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v),
("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"),
("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"),
] {
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
}
assert_eq!(bounds(&s), before, "nothing moved");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
s.execute("object.align", &json!({"horizontal": null, "vertical": "top", "to": null, "bounds": null})).unwrap();
assert!(bounds(&s).iter().all(|b| b.y0 == 0.0), "aligned to the top");
s.execute("object.distributeSpacing", &json!({"axis": null, "spacing": null})).unwrap();
assert_eq!(bounds(&s).iter().map(|b| b.x0).collect::<Vec<_>>(), [0.0, 100.0, 200.0], "equal horizontal gaps");
}
#[test]
fn align_left() {
let mut s = session();
@@ -279,6 +475,37 @@ fn align_left() {
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
}
/// Align to Artboard aligns to artboard `artboard` (the app passes the active one). Without it, or
/// with one the document doesn't have, it aligns to the artboard under the center of the
/// selection, or to the first artboard when no artboard is there. Align to Selection does not use
/// `artboard`.
#[test]
fn align_to_artboard_uses_the_artboard_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let a = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 10.0, 20.0, 20.0);
let b = rect(&mut s, boards[1].x0 + 50.0, boards[1].y0 + 40.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 2})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (boards[2].x0, boards[2].x0), "the left edges are on artboard 2's");
for (p, left) in [
(json!({"horizontal": "left", "to": "artboard"}), boards[1].x0),
(json!({"horizontal": "left", "to": "artboard", "artboard": 9}), boards[1].x0),
(json!({"horizontal": "left", "to": "selection", "artboard": 2}), boards[1].x0 + 10.0),
] {
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.align", &p).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (left, left), "{p}");
}
// A selection whose center is on no artboard aligns to the first.
let c = rect(&mut s, boards[1].x0 + 10.0, boards[1].y1 + 50.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert_eq!(x0(&s, c), boards[0].x0, "off every artboard: the first");
}
/// #541: with the Selection tool, a click on one object of the selection makes it the key object:
/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click
/// on the key again lets it go.
@@ -330,6 +557,426 @@ fn selection_tool_drag_is_one_undo_step() {
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
}
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
fn history(s: &Session) -> (usize, usize, bool) {
let st = s.doc().unwrap();
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
}
/// Select `ids` with a move of them left to redo, and mark the document saved.
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.doc_mut().unwrap().mark_saved();
}
/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a
/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo
/// stays, the document stays unmodified, and the journal, which the Actions panel records from,
/// still has the command.
#[test]
fn an_align_that_moves_nothing_records_no_undo_step() {
let mut s = session();
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let left = json!({"horizontal": "left"});
let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
// Object › Align sends no `to`, and the Align panel aligns one object to the artboard.
for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] {
s.execute("object.align", &p).unwrap();
assert_eq!(history(&s), before, "one object and no key: {p}");
assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p));
}
let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), before, "a pair already aligned");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move");
// An Align that moves something is one undo step, and it clears Redo.
let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a, c]);
let undo = history(&s).0;
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true));
assert_eq!(x0(&s, c), 5.0);
// Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a
// second Align records no step.
let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0);
let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap();
s.execute("object.align", &left).unwrap();
assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e));
s.doc_mut().unwrap().mark_saved();
let before = history(&s);
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), before, "a pair the first Align aligned");
}
/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no
/// undo step either.
#[test]
fn a_distribute_that_moves_nothing_records_no_undo_step() {
let mut s = session();
// The rectangles are 20 pt wide and 30 pt apart.
let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
select_with_a_step_to_redo(&mut s, &ids);
let before = history(&s);
let calls = [
("object.distribute", json!({"horizontal": "left"})),
("object.distribute", json!({"horizontal": "center"})),
("object.distribute", json!({"horizontal": "right"})),
("object.distributeSpacing", json!({"axis": "horizontal"})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})),
];
for (id, p) in calls {
s.execute(id, &p).unwrap();
assert_eq!(history(&s), before, "{id} {p}");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
// Distribute Spacing keeps the key object still and spaces the others from it.
s.execute("select.key", &json!({"id": ids[1].0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, Some(ids[1]));
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap();
assert_eq!(history(&s), before, "spaced 30 pt from the key object");
// After the middle one moves 10 pt right, Distribute moves it back in one undo step.
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
let undo = history(&s).0;
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!(history(&s).0, undo + 1);
assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0);
// For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a
// rounding error.
let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0));
select_with_a_step_to_redo(&mut s, &ids);
let before = history(&s);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
assert_eq!(history(&s), before, "30.1 pt apart");
// Distributing these three leaves the middle one a rounding error off halfway between the
// others, and a second Distribute records no step.
let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0));
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = history(&s);
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!(history(&s), before, "three objects the first Distribute spaced");
}
/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the
/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with
/// no object to transform and a Selection tool drag that ends where it started record no undo
/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor
/// leaves the rectangle a live shape.
#[test]
fn a_transform_that_moves_nothing_records_no_undo_step() {
let mut s = session();
// The Nudges of a guide and of anchors below use this ruler guide and this plain path.
s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap();
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap();
let path = NodeId(made["id"].as_u64().unwrap());
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
let calls = [
("object.move", json!({"dx": 0, "dy": 0})),
("object.rotate", json!({"angle": 0})),
("object.scale", json!({"sx": 100})),
("object.shear", json!({"angle": 0})),
("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
("object.nudge", json!({"dx": 0, "dy": 0})),
("object.setBounds", json!({})),
("object.transformAgain", json!({})),
];
for (id, p) in calls {
assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}");
assert_eq!(history(&s), before, "{id} {p}");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false)));
// A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape.
let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. });
assert!(is_live(&s), "a rectangle is a live shape");
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap();
assert!(is_live(&s), "a Nudge of 0 keeps it a live shape");
assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge");
// A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a
// Transform with nothing selected or with ids that name no object, with or without a copy,
// record no undo step either.
let calls = [
("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})),
("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})),
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})),
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})),
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})),
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})),
];
for (select, sp, id, p) in calls {
s.execute(select, &sp).unwrap();
s.execute(id, &p).unwrap();
assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}");
}
// A copy of no object lists no copies and keeps the selection.
let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true});
assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []}));
assert_eq!(s.doc().unwrap().selection.objects, [a]);
// A Selection tool drag that ends where it started records no undo step either. Back at the
// start, the revision advances, so views redraw the object where it was.
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0;
let revision = |s: &Session| s.doc().unwrap().revision;
s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap();
assert_ne!(x0(&s), 10.0, "the preview moves it");
let moved = revision(&s);
s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap();
assert_eq!(x0(&s), 10.0);
assert!(revision(&s) > moved, "views redraw it back at the start");
s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap();
assert_eq!(history(&s), before, "a drag back to the start");
assert_eq!(s.journal.last().unwrap().0, "object.transform");
// With a copy, a Move by 0 pt duplicates the object in one undo step.
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
let n = count(&s);
s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
assert_eq!(count(&s), n + 1);
}
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
/// Current Layer on objects already on top of the current layer.
#[test]
fn an_arrange_that_changes_no_order_records_no_undo_step() {
let mut s = session();
let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
let calls = [
(vec![ids[2]], "object.arrange.bringToFront"),
(vec![ids[2]], "object.arrange.bringForward"),
(vec![ids[0]], "object.arrange.sendToBack"),
(vec![ids[0]], "object.arrange.sendBackward"),
(vec![ids[2]], "object.arrange.sendToCurrentLayer"),
(vec![ids[1], ids[2]], "object.arrange.bringToFront"),
(vec![ids[1], ids[2]], "object.arrange.bringForward"),
(vec![ids[0], ids[1]], "object.arrange.sendToBack"),
(vec![ids[0], ids[1]], "object.arrange.sendBackward"),
(vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"),
];
for (sel, c) in calls {
select_with_a_step_to_redo(&mut s, &sel);
let before = history(&s);
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c} {sel:?}");
assert_eq!(order(&s), ids, "{c} {sel:?}");
}
// Bring to Front on the back object is one undo step, and it clears Redo.
select_with_a_step_to_redo(&mut s, &[ids[0]]);
let undo = history(&s).0;
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true));
assert_eq!(order(&s), [ids[1], ids[2], ids[0]]);
}
/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected,
/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its
/// key object.
#[test]
fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
// Expand Shape makes the two rectangles plain paths in one undo step.
let undo = history(&s).0;
s.execute("object.expandShape", &json!({})).unwrap();
assert_eq!(history(&s).0, undo + 1);
select_with_a_step_to_redo(&mut s, &[a, b]);
s.execute("select.key", &json!({"id": a.0})).unwrap();
let (before, selection) = (history(&s), s.doc().unwrap().selection.clone());
assert_eq!(selection.key, Some(a));
for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] {
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c}");
assert_eq!(s.doc().unwrap().selection, selection, "{c}");
assert_eq!(s.journal.last().unwrap().0, c);
}
// Ungroup with a group selected is one undo step.
s.execute("object.group", &json!({})).unwrap();
let undo = history(&s).0;
s.execute("object.ungroup", &json!({})).unwrap();
assert_eq!(history(&s).0, undo + 1);
}
/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the
/// selection. Lock with every selected object already locked and Hide with every one already
/// hidden record no undo step and deselect them.
#[test]
fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
for c in ["object.unlockAll", "object.showAll"] {
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
assert_eq!(history(&s), before, "{c}");
assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}");
}
// With the object locked or hidden, each is one undo step that selects it.
for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] {
s.execute(off, &json!({})).unwrap();
let undo = history(&s).0;
assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}");
assert_eq!(history(&s).0, undo + 1, "{on}");
assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}");
}
// Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it.
// Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one
// undo step.
for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] {
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
s.execute("object.setProps", &props).unwrap();
select_with_a_step_to_redo(&mut s, &[a]);
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let before = history(&s);
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c}");
assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}");
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// Set Bounds with the current position and size records no undo step on a rotated object, with
/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these
/// cases computing the transform can leave a rounding error in it.
#[test]
fn set_bounds_with_the_current_position_and_size_records_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06);
s.execute("stroke.set", &json!({"weight": 0.3})).unwrap();
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
for (id, preview) in [(a, false), (a, true), (c, true)] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap();
// The Transform panel's values: X and Y place the reference point, W and H are the sides.
let b = s.transform_box(&[id]).unwrap();
let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height());
for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] {
assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}");
assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}");
assert_eq!(s.journal.last().unwrap().0, "object.setBounds");
}
}
// A new width is one undo step, and it clears Redo.
s.execute("object.setBounds", &json!({"width": 50})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Object Properties with the values the objects already have records no undo step: every
/// property, data already set or already absent, and no object. A value that differs on one of
/// the objects is one undo step, and an id that names no object is still an error.
#[test]
fn object_properties_already_set_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}});
s.execute("object.setProps", &set).unwrap();
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
let defaults = json!({
"id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false,
"knockout": "neutral", "knockoutShape": false, "data": {"pivot": null}
});
let calls =
[set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})];
for p in calls {
s.execute("object.setProps", &p).unwrap();
assert_eq!(history(&s), before, "{p}");
assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p));
}
// `a` already has 50% opacity, and `b` does not.
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
}
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform
/// is one undo step too, because it folds the scale into the rectangle's size.
#[test]
fn live_shape_properties_already_set_record_no_undo_step() {
let mut s = session();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0);
let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap());
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap());
// The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets
// 30 pt sides.
for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] {
s.execute("object.setLiveShape", &p).unwrap();
}
let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") };
let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap());
select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]);
let before = history(&s);
let calls = [
json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}),
json!({"id": polygon.0, "sideLength": 30, "kind": "round"}),
json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}),
json!({"id": ellipse.0, "invertPie": true}),
json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}),
json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}),
json!({"id": star.0, "radius": 0}),
];
for p in calls {
s.execute("object.setLiveShape", &p).unwrap();
assert_eq!(history(&s), before, "{p}");
assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p));
}
// With an anchor of the top-left corner direct-selected, the radius applies to that corner.
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap();
assert_eq!(history(&s), before, "the Direct-Selected corner");
for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] {
let undo = history(&s).0;
s.execute("object.setLiveShape", &p).unwrap();
assert_eq!(history(&s).0, undo + 1, "{p}");
}
// The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its
// transform, as files from before #291 saved it. Setting the radius its corners show makes the
// corners circles.
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") };
let xf = Affine::scale_non_uniform(2.0, 1.0);
*live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
*path = live.to_path();
}
let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0);
let undo = history(&s).0;
s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap();
assert_eq!(history(&s).0, undo + 1, "a stretched rectangle");
}
#[test]
fn rectangle_tool_draws() {
let mut s = session();
+17
View File
@@ -187,3 +187,20 @@ fn key_anchors_round_trip() {
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
assert!(old.key_anchors.is_empty());
}
/// #1111: a straight spine's segment bends into a curve, its middle moving where it's dragged, and
/// the steps follow it; junk segments and deltas are refused.
#[test]
fn a_straight_spine_segment_bends_into_a_curve() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 0, "dy": 40})).unwrap();
let spine = spec(&s, g).spine.unwrap();
let seg = spine.subpaths[0].segment(0);
use vectorcraft_geom::kurbo::ParamCurve;
assert!(seg.eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{seg:?}");
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the curve");
for bad in [json!({"segment": 1, "dy": 10}), json!({"segment": 0, "dx": f64::MAX, "dy": f64::MAX}), json!({"segment": -1})] {
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
}
}
+4
View File
@@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() {
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
// #1009: Use Precise Cursors makes the join pointer a crosshair too.
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair);
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
+34
View File
@@ -3,8 +3,10 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::Node;
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
use crate::tooling::ViewInfo;
fn session() -> Session {
let mut s = Session::new();
@@ -99,6 +101,38 @@ fn edit_colors_reach_mesh_points() {
assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours");
}
/// Clicking a gradient mesh's point with the Mesh tool or Direct Selection picks it: a solid fill
/// colour then recolours that point alone (#1050).
#[test]
fn a_clicked_mesh_point_takes_the_fill_colour() {
let mut s = session();
let m = mesh(&mut s);
let before = mesh_colors(&s, m);
let v = ViewInfo::default();
let click = |s: &mut Session, i: usize| {
let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };
let q = g.points[i].p;
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap();
}
};
// The Mesh tool selects the mesh whose point it clicks.
s.execute("select.none", &json!({})).unwrap();
s.select_tool("mesh", v).unwrap();
click(&mut s, 1);
assert_eq!(s.doc().unwrap().selection.objects, [m]);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
let after = mesh_colors(&s, m);
assert_eq!(after[1].to_hex(), "#0000ff", "the clicked point takes the colour");
assert_eq!((after[0], after[2], after[3]), (before[0], before[2], before[3]), "the other points keep theirs");
s.select_tool("directSelection", v).unwrap();
click(&mut s, 2);
s.execute("paint.setFill", &json!({"color": "#ffff00"})).unwrap();
let after = mesh_colors(&s, m);
assert_eq!((after[1].to_hex(), after[2].to_hex()), ("#0000ff".to_string(), "#ffff00".to_string()));
assert_eq!((after[0], after[3]), (before[0], before[3]));
}
#[test]
fn blend_moves_a_mesh_to_the_blended_colour() {
let mut s = session();
+75
View File
@@ -66,6 +66,65 @@ fn recorded_actions_leave_out_replay_only_values() {
assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec());
}
/// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to
/// All on Active Artboard and to a batch (its active one) is journaled and left out of recorded
/// actions, so a played action uses the artboard active when it plays.
#[test]
fn recorded_actions_leave_out_the_artboard_the_app_passes() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2, "created": null})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let start = s.journal.len();
let steps = [
("edit.pasteInPlace", json!({"artboard": 1})),
("edit.pasteInFront", json!({"artboard": 1})),
("edit.pasteInBack", json!({"artboard": 1})),
("select.allOnArtboard", json!({"artboard": 1})),
("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})),
("command.batch", json!({"artboard": 1, "commands": [{"command": "select.allOnArtboard", "params": {}}]})),
];
for (id, p) in &steps {
s.execute(id, p).unwrap();
}
assert!(s.journal[start..].iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", &s.journal[start..]);
let action = s.journal_for_action(start);
assert_eq!(action.len(), steps.len());
for ((id, mut p), (step, recorded)) in steps.into_iter().zip(action) {
p.as_object_mut().unwrap().remove("artboard");
assert_eq!((step.as_str(), recorded), (id, p));
}
}
/// A batch passes its `artboard` (the app's active one) to each step that acts on the active
/// artboard and names none, when the step runs: once a step deletes the last artboard, the new
/// last one, and in a new document with fewer artboards, its last one. A step that names an
/// artboard keeps it.
#[test]
fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let ids: Vec<u64> = boards
.iter()
.map(|b| {
s.execute("shape.rectangle", &json!({"x": b.x0 + 10.0, "y": b.y0 + 10.0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
})
.collect();
let picked = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
let step = |command: &str, params: Value| json!({"command": command, "params": params});
let all_on_artboard = step("select.allOnArtboard", json!({}));
s.execute("command.batch", &json!({"artboard": 2, "commands": [all_on_artboard]})).unwrap();
assert_eq!(picked(&s), [ids[2]], "the batch's artboard 3");
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("select.allOnArtboard", json!({"artboard": 0}))]})).unwrap();
assert_eq!(picked(&s), [ids[0]], "an artboard the step names is kept");
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("artboard.delete", json!({"index": 2})), all_on_artboard]})).unwrap();
assert_eq!(picked(&s), [ids[1]], "artboard 2, the last one once artboard 3 is deleted");
let new = step("file.new", json!({"width": 100, "height": 100}));
s.execute("command.batch", &json!({"artboard": 2, "commands": [new, all_on_artboard]})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "in the new document");
}
#[test]
fn a_save_records_its_date_and_replays_it() {
let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
@@ -296,3 +355,19 @@ fn an_undo_group_is_one_undo_step() {
assert!((width(&s) - 120.0).abs() < 1e-9);
assert_eq!(undo_len(&s), undo + 2);
}
/// A move drag is named for what its last preview did: Alt pressed or released during the drag
/// turns it into a copy or back (the Selection and Direct Selection tools).
#[test]
fn a_move_drag_is_named_for_whether_it_copied() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let last = |s: &Session| s.doc().unwrap().history.undo.last().map(|e| e.label.clone());
for (begin, copy, label) in [("Move", true, "Copy"), ("Copy", false, "Move"), ("Move", false, "Move")] {
s.begin_interaction(begin).unwrap();
s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": copy})).unwrap();
s.commit_interaction().unwrap();
assert_eq!(last(&s).as_deref(), Some(label), "begun as {begin}, copy: {copy}");
}
}
+100
View File
@@ -206,6 +206,89 @@ fn visibility_and_lock_per_row_are_undoable() {
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
}
#[test]
fn object_set_props_deselects_the_objects_it_hides_or_locks() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
for (prop, value) in [("visible", false), ("locked", true)] {
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "select.key", json!({"id": b.0}));
let mut p = json!({"ids": [b.0]});
p[prop] = json!(value);
run(&mut s, "object.setProps", p);
assert!(!s.doc().unwrap().doc.is_editable(b));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a], None), "{prop}: {value} deselects the key object");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.is_editable(b));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a, b], Some(b)), "undoing {prop}: {value} selects it again as the key");
}
}
#[test]
fn object_set_props_deselects_the_objects_inside_a_group_or_layer_it_hides_or_locks() {
let mut s = session();
let (_, sub, a, g, b, c) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "object.setProps", json!({"ids": [g.0], "locked": true}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "b is in the locked group");
run(&mut s, "select.key", json!({"id": a.0}));
assert_eq!(s.doc().unwrap().selection.key, Some(a));
run(&mut s, "object.setProps", json!({"ids": [sub.0], "visible": false}));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![c], None), "a, the key object, is in the hidden sublayer");
}
#[test]
fn object_set_props_that_hides_or_locks_nothing_keeps_the_selection() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let x = rect(&mut s, 0.0);
run(&mut s, "select.set", json!({"ids": [x.0]}));
run(&mut s, "object.lock", json!({}));
run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true}));
run(&mut s, "object.unlockAll", json!({}));
let st = s.doc().unwrap();
assert!(st.selection.objects == vec![x] && !st.doc.is_editable(x), "Unlock All selects x, and its layer still locks it");
for p in [json!({"opacity": 50}), json!({"visible": true, "locked": false})] {
run(&mut s, "object.setProps", p.clone());
assert_eq!(s.doc().unwrap().selection.objects, vec![x], "{p} keeps x selected");
}
assert_eq!(node(&s, x).opacity, 0.5);
}
#[test]
fn object_set_props_that_hides_or_locks_deselects_objects_hidden_or_locked_before() {
let mut s = session();
let (x, y, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0), rect(&mut s, 200.0));
run(&mut s, "object.setProps", json!({"ids": [x.0], "visible": false}));
run(&mut s, "select.set", json!({"ids": [x.0, y.0, z.0]}));
assert_eq!(s.doc().unwrap().selection.objects, vec![x, y, z], "select.set selects the hidden x");
run(&mut s, "object.setProps", json!({"ids": [y.0], "locked": true}));
assert_eq!(s.doc().unwrap().selection.objects, vec![z], "locking y deselects x, hidden before the call, too");
}
#[test]
fn object_set_props_that_changes_nothing_deselects_what_it_leaves_hidden_or_locked() {
let mut s = session();
let (x, y) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
for (prop, value) in [("visible", false), ("locked", true)] {
let mut p = json!({"ids": [x.0]});
p[prop] = json!(value);
run(&mut s, "object.setProps", p.clone());
run(&mut s, "select.set", json!({"ids": [x.0, y.0]}));
run(&mut s, "select.key", json!({"id": x.0}));
let before = undos(&s);
run(&mut s, "object.setProps", p);
assert_eq!(undos(&s), before, "{prop}: {value} again records no undo step");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![y], None), "{prop}: {value} again deselects x, the key object");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.is_editable(x));
}
}
#[test]
fn layer_options_validate_and_template_locks_and_dims() {
let mut s = session();
@@ -460,6 +543,23 @@ fn hide_lock_and_outline_others_and_their_all_counterparts() {
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
}
/// #1158: Alt-click on a hidden (locked) layer's eye (lock) isolates it: it shows (unlocks) as the
/// others hide (lock), in one undo step.
#[test]
fn hide_and_lock_others_show_and_unlock_the_layer_kept() {
let mut s = session();
let l1 = s.doc().unwrap().doc.layers[0].id;
let l2 = id(&run(&mut s, "layer.new", json!({})));
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2].map(|l| f(&node(s, l)));
run(&mut s, "layer.setProps", json!({"ids": [l2.0], "visible": false, "locked": true}));
assert_eq!(run(&mut s, "layer.hideOthers", json!({"ids": [l2.0]}))["count"], 2);
assert_eq!(flags(&s, |n| n.visible), [false, true]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(flags(&s, |n| n.visible), [true, false], "one undo step");
run(&mut s, "layer.lockOthers", json!({"ids": [l2.0]}));
assert_eq!(flags(&s, |n| n.locked), [true, false]);
}
#[test]
fn locate_object_highlights_its_row() {
let mut s = session();
+198 -5
View File
@@ -94,6 +94,49 @@ fn hide_all_artwork_above() {
assert!(node(&s, over).visible);
}
fn key(s: &Session) -> Option<NodeId> {
s.doc().unwrap().selection.key
}
/// Lock › All Artwork Above deselects the selected objects it locks: here the key object, in a
/// group above the other selected object. Undo selects it again as the key object.
#[test]
fn lock_all_artwork_above_deselects_the_objects_it_locks() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
sel(&mut s, &[b]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
sel(&mut s, &[a, b]);
s.execute("select.key", &json!({"id": b.0})).unwrap();
s.execute("object.lock.above", &json!({})).unwrap();
assert!(node(&s, g).locked);
assert_eq!((selected(&s), key(&s)), (vec![a], None));
s.execute("edit.undo", &json!({})).unwrap();
assert!(!node(&s, g).locked);
assert_eq!((selected(&s), key(&s)), (vec![a, b], Some(b)));
}
/// Hide › All Artwork Above deselects the selected objects it hides (here one in a sublayer above)
/// and selected objects that were hidden before. The one object left can't be the key object, so
/// the key goes too; undo brings both back.
#[test]
fn hide_all_artwork_above_deselects_the_objects_it_hides() {
let mut s = session();
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let sub = id_of(&s.execute("layer.newSublayer", &json!({})).unwrap());
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
sel(&mut s, &[hidden, a, b]);
s.execute("select.key", &json!({"id": a.0})).unwrap();
s.execute("object.hide.above", &json!({})).unwrap();
assert!(!node(&s, sub).visible);
assert_eq!((selected(&s), key(&s)), (vec![a], None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
}
#[test]
fn lock_and_hide_other_layers() {
let mut s = session();
@@ -785,7 +828,11 @@ fn ruler_guides_select_move_and_delete() {
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0]));
assert!(selected(&s).is_empty());
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true}));
let layer = s.doc().unwrap().current_layer().unwrap().0;
assert_eq!(
s.execute("guide.list", &json!({})).unwrap()[1],
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true})
);
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
// A move takes each selected guide along its own axis, in one undo step.
let n = undo_len(&s);
@@ -823,6 +870,40 @@ fn ruler_guides_select_move_and_delete() {
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
}
/// Shift selects ruler guides and art together, in either order; Delete takes both in one step.
#[test]
fn guides_and_art_are_shift_selected_together() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0);
let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
// Art first, then a Shift-click on a guide: the art stays selected.
sel(&mut s, &[a]);
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0]));
// A guide first, then Shift-added art: the guide stays selected.
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1]));
// A new selection drops them.
sel(&mut s, &[a]);
assert!(selected_guides(&s).is_empty());
// Delete takes the art and the guides, in one step; undo brings both back.
s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap();
let n = undo_len(&s);
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1));
assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
// Ids given: only those go, the guides stay.
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap();
assert_eq!(guides(&s).len(), 2);
}
/// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved,
/// copied and deleted with it; canvas guides stay put.
#[test]
@@ -832,11 +913,12 @@ fn artboard_guides_go_with_their_artboard() {
s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
let layer = s.doc().unwrap().current_layer().unwrap().0;
assert_eq!(
list(&mut s),
json!([
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1},
{"index": 1, "vertical": false, "pos": 100.0, "selected": false},
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true},
{"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true},
])
);
let spans = |s: &Session| {
@@ -875,7 +957,10 @@ fn artboard_guides_go_with_their_artboard() {
let n = undo_len(&s);
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true}));
assert_eq!(
list(&mut s)[0],
json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true})
);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
@@ -917,12 +1002,120 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
s.set_tool_option("active", &json!(1));
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
let layer = s.doc().unwrap().current_layer().unwrap().0;
assert_eq!(
s.execute("guide.list", &json!({})).unwrap()[1],
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1})
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true})
);
}
/// Guides saved before guides had layers are put on a layer when the document opens.
#[test]
fn guides_from_older_files_are_put_on_a_layer() {
let mut s = Session::new();
let mut d = vectorcraft_doc::Document::new(400.0, 300.0);
d.guides.push(vectorcraft_doc::Guide::new(true, 50.0));
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
s.add_document(d, None);
let st = s.doc().unwrap();
let top = st.doc.default_layer();
assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides);
}
/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it,
/// and moved to another layer.
#[test]
fn ruler_guides_live_on_layers() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer");
let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current");
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err());
// Hidden or locked with its layer.
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false);
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
let row = s.execute("guide.list", &json!({})).unwrap()[1].clone();
assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false)));
s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap();
// Moved to the first layer, then back.
s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first));
s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap();
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err());
// Several at once, as dragging their rows onto a layer's row does.
s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap();
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first)));
s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap();
assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err());
// Deleting a layer deletes its guides; undo brings them back.
s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap();
assert_eq!(guides(&s), [(true, 200.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
}
/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to
/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a
/// locked layer.
#[test]
fn guides_on_hidden_or_locked_layers_are_left_alone() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap();
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap();
assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected");
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected");
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err());
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err());
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer");
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected");
// Both layers hidden or locked: a new guide is refused, as new art is.
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err());
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap();
// The current layer locked: the new guide goes on the top shown, unlocked layer.
s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first));
assert_eq!(guides(&s).len(), 3);
}
/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo
/// step each.
#[test]
fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::<Vec<_>>();
// Duplicate Layer copies its guides onto the copy.
let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap();
assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
// Merged into the first layer, the second layer's guide goes with its art.
s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap();
assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)]));
// Flattened into the first layer, a hidden layer is discarded with its guides.
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
s.execute("layer.flatten", &json!({"id": first.0})).unwrap();
assert_eq!(guides(&s), [(true, 200.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
}
/// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart
/// Guides).
#[test]
+39
View File
@@ -182,3 +182,42 @@ fn shadows_and_glows_keep_their_blend_modes() {
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
}
#[test]
fn pdf_resolution_cap_warns_once_for_original_object_when_appearance_items_split() {
let mut d = Document::new(300.0, 300.0);
d.raster_effects_ppi = 2400.0;
d.raster_effects.anti_alias = false;
let mut n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 270.0, 270.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0),
);
let id = n.id;
n.name = Some("Large original".into());
for item in &mut n.appearance.items {
item.effects_mut().push(Effect { params: json!({"radius": 0.0}), ..blur() });
}
add(&mut d, n);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
let warning = &r.warnings[0];
assert!(warning.contains(&format!("object {}", id.0)) && warning.contains("Large original"), "{warning}");
assert!(warning.contains("requested 2400.0 ppi") && warning.contains("achieved 1268.7 ppi"), "{warning}");
}
#[test]
fn pdf_resolution_cap_stays_quiet_when_small_effect_meets_requested_ppi() {
let mut d = Document::new(50.0, 50.0);
d.raster_effects_ppi = 300.0;
let n = with(rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), Color::BLACK), blur());
add(&mut d, n);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
+29
View File
@@ -346,3 +346,32 @@ fn bad_place_params_are_errors() {
assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone");
assert!(top_children(&s).is_empty());
}
/// An SVG placed with `crop: "crop"` given is framed and clipped by its viewBox, as a PDF by its
/// page, so art drawn on a shared canvas keeps its place and scale; by default (and with
/// "bounding") it is fitted by its art's bounds (#788).
#[test]
fn an_svg_placed_with_crop_is_framed_by_its_viewbox() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M200 300 L600 300 L600 800 L200 800 Z"/></svg>"#;
let path_bounds = |s: &Session, id: &Value| {
let mut b = None;
node(s, id).walk(&mut |n| {
if let NodeKind::Path { clipping: false, .. } = n.kind {
b = n.visual_bounds();
}
});
b.unwrap()
};
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6);
let mut s = session();
let r = place(&mut s, "glyph.svg", svg, json!({"rect": [100, 100, 200, 200], "crop": "crop"}));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(200.0)), "{r}");
let id = &r["ids"][0];
assert!(near(path_bounds(&s, id), Rect::new(140.0, 160.0, 220.0, 260.0)), "{:?}", path_bounds(&s, id));
assert!(matches!(node(&s, id).kind, NodeKind::Group { clip: true, .. }), "clipped to the viewBox");
for extra in [json!({"rect": [100, 100, 200, 200]}), json!({"rect": [100, 100, 200, 200], "crop": "bounding"})] {
let mut s = session();
let r = place(&mut s, "glyph.svg", svg, extra);
assert!(near(path_bounds(&s, &r["ids"][0]), Rect::new(120.0, 100.0, 280.0, 300.0)), "{r}");
}
}
+61
View File
@@ -156,6 +156,23 @@ fn gpu_preference_defaults_to_automatic_and_validates() {
assert_eq!(s.prefs.gpu_preference, "automatic");
}
/// Performance › Linux Display Server (#1151): automatic by default, X11 by value or label, and
/// anything else is an error that changes nothing.
#[test]
fn linux_display_server_defaults_to_automatic_and_validates() {
let mut s = Session::new();
assert_eq!(s.prefs.linux_display_server, "automatic");
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "X11 (XWayland)"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "x11");
for bad in [json!("wayland"), json!(""), json!(11), json!(null)] {
assert!(s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": bad})).is_err(), "{bad}");
assert_eq!(s.prefs.linux_display_server, "x11");
}
assert_eq!(s.prefs.to_json()["linuxDisplayServer"], "x11");
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "automatic");
}
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them
/// (#394).
@@ -452,6 +469,50 @@ fn transform_pattern_tiles_is_the_transforms_default() {
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
}
/// General › Transform Pattern Tiles (#394) applies to Align and Distribute as it does to the
/// transforms: off, the tiles of a pattern-filled square stay in place when the square moves;
/// on, they move with it, and `patterns: false` keeps them in place for one call.
#[test]
fn align_and_distribute_follow_transform_pattern_tiles() {
use vectorcraft_color::Paint;
let mut s = new_doc();
let plain = square(&mut s, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [plain.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let far = square(&mut s, 300.0, 0.0);
let filled = square(&mut s, 100.0, 0.0);
s.execute("paint.setFill", &json!({"ids": [filled.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [plain.0, filled.0, far.0]})).unwrap();
let tiles = |s: &Session| match s.doc().unwrap().doc.node(filled).unwrap().appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
};
// Align left moves the square 100 pt left, to the left edge of `plain`. Both Distribute
// commands move it 50 pt right, halfway between the other two.
for (cmd, p, dx) in [
("object.align", json!({"horizontal": "left"}), -100.0),
("object.distribute", json!({"horizontal": "left"}), 50.0),
("object.distributeSpacing", json!({"axis": "horizontal"}), 50.0),
] {
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(true));
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, dx, 0.0], "{cmd}: on, they move with the square");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
let mut p = p;
p["patterns"] = json!(false);
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(false));
}
}
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
/// swatch; on, only the same tint.
#[test]
+153
View File
@@ -0,0 +1,153 @@
//! A reflected object is still clicked by its fill, not only by its outline.
use serde_json::{Value, json};
use vectorcraft_geom::Point;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A click with the Selection tool at (x, y): what it selects.
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap();
}
s.doc().unwrap().selection.objects.clone()
}
#[test]
fn a_reflected_shape_is_clicked_by_its_fill() {
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
let makers: [(&str, Value); 5] = [
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
("shape.ellipse", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 5})),
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
("path.create", json!({"d": HEART})),
];
for (cmd, params) in makers {
for axis in ["horizontal", "vertical"] {
let mut s = session();
let id = id_of(&s.execute(cmd, &params).unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
let c = b.center();
assert_eq!(click(&mut s, c.x, c.y), vec![id], "{cmd} reflected {axis}: a click in its fill at {c:?} (bounds {b:?})");
}
}
}
/// Hearts as SVG files bring them: a plain path, a compound path, inside a group, under a
/// transform. Reflected with the button and with a bounding-box handle dragged across.
#[test]
fn a_reflected_imported_heart_is_clicked_by_its_fill() {
const D: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
let files = [
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" d="{D}"/></svg>"##),
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" fill-rule="evenodd" d="{D} M190 200h20v20h-20z"/></svg>"##
),
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g><g fill="#e2384d"><path d="{D}"/></g></g></svg>"##),
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g transform="translate(10 0) scale(1.2)"><path fill="#e2384d" transform="matrix(1 0 0 1 -20 -20)" d="{D}"/></g></svg>"##
),
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 200 200"><path style="fill:#e2384d;stroke:none" d="{D}" transform="scale(0.5)"/></svg>"##
),
];
for (i, svg) in files.iter().enumerate() {
for how in ["horizontal", "vertical", "handle"] {
let mut s = Session::new();
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
s.execute("select.all", &json!({})).unwrap();
let ids = s.doc().unwrap().selection.objects.clone();
assert!(!ids.is_empty(), "file {i}: nothing imported");
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
if how == "handle" {
// Drag the top handle down past the bottom: a top-to-bottom flip.
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let (from, to) = (Point::new(b.center().x, b.y0), Point::new(b.center().x, b.y1 + b.height()));
for (kind, p) in
[(PointerKind::Down, from), (PointerKind::Drag, Point::new(from.x, b.center().y)), (PointerKind::Drag, to), (PointerKind::Up, to)]
{
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
}
} else {
s.execute("object.reflect", &json!({"axis": how})).unwrap();
}
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
// Inside the heart away from the compound's square hole: a third of the way down.
let c = Point::new(b.center().x, if how == "horizontal" || how == "handle" { b.y1 - b.height() * 0.3 } else { b.y0 + b.height() * 0.3 });
let picked = click(&mut s, c.x, c.y);
assert_eq!(picked, ids, "file {i} flipped by {how}: a click in its fill at {c:?} (bounds {b:?})");
}
}
}
/// Still selected after the flip, a drag from inside the fill moves it.
#[test]
fn a_reflected_selected_shape_drags_by_its_fill() {
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
let makers: [(&str, Value); 4] = [
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 3})),
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
("path.create", json!({"d": HEART})),
];
for (cmd, params) in makers {
for axis in ["horizontal", "vertical"] {
let mut s = session();
let id = id_of(&s.execute(cmd, &params).unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
let c = b.center();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for (kind, x) in
[(PointerKind::Down, c.x), (PointerKind::Drag, c.x + 20.0), (PointerKind::Drag, c.x + 40.0), (PointerKind::Up, c.x + 40.0)]
{
s.pointer(&PointerEvent::new(kind, x, c.y).with_mods(Mods::default()), v).unwrap();
}
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
assert!((after.x0 - b.x0 - 40.0).abs() < 1e-6, "{cmd} reflected {axis}: a drag from {c:?} left it at {after:?} (was {b:?})");
}
}
}
/// An open path (an anchor deleted from a closed one) still fills as if closed, and its fill is
/// clicked on both sides of that closing line, reflected or not.
#[test]
fn an_open_filled_path_is_clicked_by_its_fill_either_way_round() {
// The sliver of a strawberry, open, its closing line down its left side.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path fill="#c22a3f" d="M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61"/></svg>"##;
for axis in [None, Some("vertical"), Some("horizontal")] {
let mut s = Session::new();
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
s.execute("select.all", &json!({})).unwrap();
let ids = s.doc().unwrap().selection.objects.clone();
if let Some(axis) = axis {
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
}
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
// The middle of the sliver's widest part, well inside it.
let x = if axis == Some("vertical") { b.x0 + b.width() * 0.4 } else { b.x0 + b.width() * 0.6 };
assert_eq!(click(&mut s, x, b.center().y), ids, "reflected {axis:?}: a click in the fill (bounds {b:?})");
}
}
+16
View File
@@ -42,6 +42,22 @@ fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
})
}
/// An invalid explicit Shaper target must never silently change which shapes get edited.
#[test]
fn shaper_scribble_rejects_malformed_explicit_targets() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let untouched = s.doc().unwrap().doc.layers.clone();
let undo = s.doc().unwrap().history.undo.len();
for ids in [json!([a.0, "bad"]), json!([a.0, 999999]), json!({"id": a.0}), json!("bad")] {
let err = s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": ids}));
assert!(err.is_err(), "malformed targets were accepted");
assert_eq!(s.doc().unwrap().doc.layers, untouched);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": [a.0]})).unwrap();
}
#[test]
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
for reverse in [false, true] {
+20
View File
@@ -283,6 +283,26 @@ fn swatch_exchange_files_save_with_models_kinds_and_groups() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn save_counts_the_swatches_the_file_holds() {
let mut s = session();
// A new document has 46 solid colors and 4 linear or radial gradients besides None. Mesh is a freeform gradient.
run(&mut s, "swatch.new", json!({"name": "Mesh", "gradient": {"kind": "freeform"}}));
let mut counts = vec![];
for format in ["vcswatches", "gpl", "ase", "css"] {
let r = run(&mut s, "swatch.library.save", json!({ "format": format }));
// The libraries load back with that many swatches, and the CSS file has one property for each.
let held = if format == "css" {
r["data"].as_str().unwrap().lines().filter(|l| l.starts_with(" --")).count() as u64
} else {
let load = json!({"data": r["data"], "dataBase64": r["dataBase64"], "name": format!("Saved.{format}")});
run(&mut s, "swatch.library.load", load)["count"].as_u64().unwrap()
};
counts.push((format, r["count"].as_u64().unwrap(), held));
}
assert_eq!(counts, [("vcswatches", 51, 51), ("gpl", 46, 46), ("ase", 46, 46), ("css", 50, 50)]);
}
#[test]
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
use vectorcraft_testkit::ase::{Block, ase, sample};
+32
View File
@@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
legacy["para"]["justify"] = json!("Left");
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
}
/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just
/// those characters, as one undo step; the rest of the type keeps its colour.
#[test]
fn a_fill_chosen_with_characters_selected_colours_only_them() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 50.0, 300.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Sample Text", v).unwrap();
let before = obj(&s, id).runs[0].style.fill.clone();
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
let t = obj(&s, id);
assert_eq!(t.runs.len(), 2);
assert_eq!(t.runs[0].text, "Sample");
assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0)));
assert_eq!(t.runs[1].text, " Text");
assert_eq!(t.runs[1].style.fill, before);
// A stroke goes on the selected characters only, with a weight to show.
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let t = obj(&s, id);
assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none());
assert!(t.runs[1].style.stroke.is_none());
// One undo step each.
let labels = undo_labels(&s);
assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
}
+229
View File
@@ -0,0 +1,229 @@
//! #1034: Character sizes and horizontal scales account for the object's affine transform.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
use vectorcraft_geom::Point;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn create(s: &mut Session, size: f64) -> NodeId {
NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap())
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn em(t: &TextObject, run: usize) -> (f64, f64) {
let st = &t.runs[run].style;
let origin = t.xf * Point::ZERO;
((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot())
}
fn near(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
}
#[test]
fn scaled_font_size_setter_sets_document_points_and_undoes() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("object.scale", &json!({"sx": 150})).unwrap();
let before = text(&s, id);
near(em(&before, 0).1, 36.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
let after = text(&s, id);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(text(&s, id), after);
}
#[test]
fn group_font_size_setter_handles_different_accumulated_scales() {
let mut s = session();
let a = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let b = create(&mut s, 18.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("object.scale", &json!({"sx": 200})).unwrap();
near(em(&text(&s, a), 0).1, 48.0);
near(em(&text(&s, b), 0).1, 36.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
near(em(&text(&s, a), 0).1, 10.0);
near(em(&text(&s, b), 0).1, 10.0);
}
#[test]
fn scaled_range_size_and_keyboard_steps_use_document_points() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap();
near(em(&text(&s, id), 0).1, 10.0);
near(em(&text(&s, id), 1).1, 24.0);
s.prefs.type_size_increment = 2.0;
s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap();
near(em(&text(&s, id), 0).1, 12.0);
near(em(&text(&s, id), 1).1, 24.0);
}
#[test]
fn horizontal_scale_reset_removes_object_stretch_including_groups() {
for grouped in [false, true] {
let mut s = session();
let id = create(&mut s, 12.0);
if grouped {
s.execute("object.group", &json!({})).unwrap();
}
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let before = text(&s, id);
s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 12.0);
near(em(&after, 0).1, 12.0);
assert_eq!(after.xf, before.xf);
}
}
#[test]
fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() {
let mut s = session();
let id = create(&mut s, 12.0);
// A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3.
s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap();
let before = text(&s, id);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 10.0);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf);
// The native document representation retains both the compensation and the affine.
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
let reopened = vectorcraft_format::load(&bytes).unwrap();
let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") };
near(em(t, 0).0, 10.0);
near(em(t, 0).1, 10.0);
}
#[test]
fn effective_range_query_leaves_raw_runs_available_for_clipboard() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
let query = |s: &mut Session, effective| -> Value {
s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone()
};
let effective = query(&mut s, true);
assert_eq!(effective["size"], 24.0);
assert_eq!(effective["h_scale"], 150.0);
let raw = query(&mut s, false);
assert_eq!(raw["size"], 12.0);
assert_eq!(raw["h_scale"], 100.0);
}
#[test]
fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap();
let t = text(&s, id);
near(em(&t, 0).1, 0.1);
near(t.runs[0].style.leading.unwrap() * 2.0, 14.0);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap();
let t = text(&s, id);
near(t.runs[0].style.baseline_shift * 2.0, 4.0);
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
}
#[test]
fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() {
let mut s = session();
let a = create(&mut s, 12.0);
let b = create(&mut s, 18.0);
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err());
assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err());
// Non-dimensional edits remain possible, including on imported collapsed text.
s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap();
}
#[test]
fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap();
let before = text(&s, id);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 10.0);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf);
}
#[test]
fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() {
let mut s = session();
let id = NodeId(
s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap();
near(em(&text(&s, id), 0).1, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
near(em(&text(&s, id), 0).1, 24.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
near(em(&text(&s, id), 0).1, 10.0);
}
#[test]
fn point_based_keyboard_increments_and_format_fields_follow_document_units() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("text.setStyle", &json!({"leading": 14})).unwrap();
s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap();
s.prefs.type_size_increment = 2.0;
s.prefs.baseline_shift_increment = 3.0;
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap();
let t = text(&s, id);
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
near(t.runs[0].style.baseline_shift * 2.0, 7.0);
}
#[test]
fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap();
let before = text(&s, id);
let history = s.doc().unwrap().history.undo.len();
let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}");
assert_eq!(text(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
+45
View File
@@ -30,6 +30,51 @@ fn dark(s: &Session, x: u32, y: u32) -> bool {
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128
}
/// Invalid entries in an explicit multi-point delete must not delete the valid entries.
#[test]
fn invalid_width_point_indices_leave_the_entire_profile_unchanged() {
let (mut s, id) = line_session();
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
let before = points(&s, id);
let history = s.doc().unwrap().history.undo.len();
for bad in [
json!({"indices": [1, "bad"]}),
json!({"indices": [-1, 1]}),
json!({"indices": [1.5, 1]}),
json!({"indices": {"point": 1}}),
json!({"index": "bad"}),
] {
let mut params = bad.as_object().unwrap().clone();
params.insert("id".into(), json!(id.0));
let err = s.execute("stroke.widthPoint.remove", &serde_json::Value::Object(params));
assert!(err.is_err(), "accepted malformed width point indices");
assert_eq!(points(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [1]})).unwrap();
assert_eq!(points(&s, id).len(), before.len() - 1);
}
/// Setting or copying a width point with a malformed `index` is refused like one out of range: it
/// never adds a point instead, or wraps a huge index round. A null `index` is no index (a new point).
#[test]
fn a_malformed_width_point_index_is_refused_by_set_and_copy() {
let (mut s, id) = line_session();
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
let before = points(&s, id);
let history = s.doc().unwrap().history.undo.len();
for index in [json!("bad"), json!(-1), json!(1.5), json!([1]), json!(u64::MAX), json!(before.len())] {
for cmd in ["stroke.widthPoint.set", "stroke.widthPoint.copy"] {
let r = s.execute(cmd, &json!({"id": id.0, "index": index, "t": 0.3, "left": 4, "right": 4}));
assert!(r.is_err(), "{cmd} took index {index}");
assert_eq!(points(&s, id), before, "{cmd} with index {index}");
}
}
assert_eq!(s.doc().unwrap().history.undo.len(), history);
set(&mut s, json!({"id": id.0, "index": null, "t": 0.3, "left": 4, "right": 4}));
assert_eq!(points(&s, id).len(), before.len() + 1, "a null index adds a point");
}
#[test]
fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() {
let (mut s, id) = line_session();
+60
View File
@@ -104,6 +104,37 @@ fn reflect_and_shear_tools() {
assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}");
}
/// Reflect and Shear return an error for an `axis` they don't take (Reflect's names it and also
/// takes an angle): nothing moves and no undo step is recorded. A null `axis` counts as not given: Reflect
/// then reflects across the vertical axis and Shear shears along the horizontal one.
#[test]
fn reflect_and_shear_reject_unknown_axes_without_changing_state() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 30.0, 10.0);
let n = undo_len(&s);
let reflect = "axis must be \"vertical\", \"horizontal\" or an angle in degrees";
let shear = "`axis` must be one of horizontal, vertical";
for (cmd, p, msg) in [
("object.reflect", json!({"axis": "diagonal", "origin": [0, 0]}), format!("{reflect}, not `diagonal`")),
("object.reflect", json!({"axis": "45", "origin": [0, 0]}), format!("{reflect}, not `45`")),
("object.reflect", json!({"axis": true, "origin": [0, 0]}), format!("{reflect}, not `true`")),
("object.shear", json!({"angle": 30, "axis": "diagonal"}), shear.to_string()),
("object.shear", json!({"angle": 30, "axis": 90}), shear.to_string()),
] {
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
}
assert!(close(bounds(&s, a), Rect::new(10.0, 20.0, 40.0, 30.0)), "nothing moved: {:?}", bounds(&s, a));
assert_eq!(undo_len(&s), n, "no undo step");
s.execute("object.reflect", &json!({"axis": null, "origin": [0, 0]})).unwrap();
assert!(close(bounds(&s, a), Rect::new(-40.0, 20.0, -10.0, 30.0)), "across the vertical axis: {:?}", bounds(&s, a));
s.execute("object.reflect", &json!({"axis": 0, "origin": [0, 0]})).unwrap();
assert!(close(bounds(&s, a), Rect::new(-40.0, -30.0, -10.0, -20.0)), "an angle still reflects: {:?}", bounds(&s, a));
s.execute("object.shear", &json!({"angle": 45, "axis": null, "origin": [0, 0]})).unwrap();
let b = bounds(&s, a);
assert!((b.y0 + 30.0).abs() < 1e-6 && (b.y1 + 20.0).abs() < 1e-6 && b.width() > 30.0, "along the horizontal axis: {b:?}");
}
#[test]
fn distort_command_maps_corners() {
let mut s = session();
@@ -596,3 +627,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap();
assert_eq!(undo_len(&s), history + 1);
}
/// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step
/// each, the art on each moving with it, and one artboard always left.
#[test]
fn several_artboards_move_and_delete_together() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 1400, "y": 0, "width": 200, "height": 200})).unwrap();
let r = rect(&mut s, 1050.0, 50.0, 50.0, 50.0);
let before: Vec<Rect> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let out = s.execute("artboard.move", &json!({"indices": [2, 1], "dx": 10, "dy": 20, "moveArt": true})).unwrap();
assert_eq!(out["indices"], json!([1, 2]));
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect, before[0]);
assert_eq!(
(d.artboards[1].rect, d.artboards[2].rect),
(before[1] + vectorcraft_geom::Vec2::new(10.0, 20.0), before[2] + vectorcraft_geom::Vec2::new(10.0, 20.0))
);
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 1060.0, "the art on artboard 2 moved with it");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[2].rect, before[2], "one undo step");
for bad in [json!({"indices": [1, 9]}), json!({"indices": []}), json!({"indices": [-1]}), json!({"indices": "1"})] {
assert!(s.execute("artboard.move", &json!({"indices": bad["indices"], "dx": 1})).is_err(), "{bad}");
}
assert!(s.execute("artboard.delete", &json!({"indices": [0, 1, 2]})).is_err(), "one is always left");
s.execute("artboard.delete", &json!({"indices": [2, 0]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [before[1]]);
}
+4 -23
View File
@@ -81,23 +81,6 @@ fn every_command_without_a_document() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the
/// junk sweep so the rest of the sweep stays meaningful.
const KNOWN_BUGS: &[(&str, &str)] = &[
("object.scale", "sx"),
("object.transformEach", "scaleH"),
("object.transformEach", "scaleV"),
("object.distributeSpacing", "spacing"),
("artboard.setProps", "x"),
("artboard.setProps", "y"),
("artboard.setProps", "width"),
("artboard.setProps", "height"),
];
fn known_bug(id: &str, p: &Value) -> bool {
KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some())
}
/// Regression: huge column counts used to panic with `capacity overflow`; in every layout and
/// order (#681), with huge and negative spacing too.
#[test]
@@ -157,9 +140,6 @@ fn every_command_with_junk_params() {
cases.truncate(12);
}
for p in &cases {
if known_bug(c.id, p) {
continue;
}
calls += 1;
if let Some(f) = probe(fx, c.id, p) {
failures.push(f);
@@ -341,6 +321,9 @@ fn structured_junk() {
("object.setBounds", json!({"width": 0, "height": 0})),
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
("object.distributeSpacing", json!({"axis": "vertical", "spacing": 0})),
("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard", "bounds": "preview"})),
("object.distribute", json!({"vertical": "center", "bounds": "geometric"})),
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
@@ -384,6 +367,7 @@ fn structured_junk() {
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),
@@ -494,9 +478,6 @@ fn fuzzed_calls_keep_documents_serializable() {
let k = if k == "path" { continue } else { k };
let mut s = Fixture::Multi.session();
let p = json!({ k.clone(): v });
if known_bug(c.id, &p) {
continue;
}
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("{} {p}: {e}", c.id));
+55 -4
View File
@@ -1280,11 +1280,17 @@ proptest! {
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
/// as files in the legacy Illustrator format too (the creator line is what turns on its
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips.
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which
/// the editing data reader reads from the program too.
#[test]
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>()) {
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>(), layers in any::<bool>()) {
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" "));
let (open, close) = if layers {
("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--")
} else {
("", "")
};
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\nshowpage\n", tokens.join(" "));
survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?;
let r = catch_quiet(|| {
let mut s = rich_session();
@@ -1702,6 +1708,9 @@ fn ascii85(data: &[u8]) -> String {
out
}
/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end.
const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n";
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
fn editing_text() -> String {
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
@@ -1724,13 +1733,16 @@ fn editing_text() -> String {
"0 Xw".into(),
"1 0 0 0 1 0 Bg".into(),
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),
square(60),
]
.join("\n");
let layer = |name: &str, visible: u8, body: &str| {
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
};
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n",
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
layer("Two", 0, &square(80))
)
@@ -1872,6 +1884,35 @@ proptest! {
}
}
/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named,
/// not included, so its program can't be run and its layers are read from it.
const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\
%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\
u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n";
/// The fixture, as it is, opens with its layer and sublayer.
#[test]
fn the_named_prolog_fixture_opens_with_its_layers() {
let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap();
let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings);
assert_eq!(l.doc.layers[0].children().unwrap().len(), 3);
}
proptest! {
#![proptest_config(config())]
/// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its
/// layers, or refused.
#[test]
fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) {
let text = mutate_text(NAMED_PROLOG_AI, cut, &edits);
survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?;
}
}
/// The operators, operands and section comments of hostile editing data.
fn arb_ai_token() -> impl Strategy<Value = String> {
prop_oneof![
@@ -1980,6 +2021,16 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"/Binary : /ASCII85Decode ,",
"~>",
"p",
"P",
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p",
"(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P",
"Xk",
"XK",
"0 0 0 1 (Global) 0.4 0 Xk",
"1 0.5 0 (Global) 0 1 XK",
"E",
"\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n",
"\n%AI3_EndPattern\n",
"To",
"frobnicate",
"\n%AI5_BeginLayer\n",
+10
View File
@@ -81,6 +81,16 @@ impl<'a> Lexer<'a> {
rest.get(..n).unwrap_or_default()
}
/// Where the next token is looked for.
pub fn offset(&self) -> usize {
self.pos
}
/// The data from `from` to `to` (empty where that isn't in it).
pub fn slice(&self, from: usize, to: usize) -> &'a [u8] {
self.src.get(from..to).unwrap_or_default()
}
/// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped.
pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] {
let rest = self.rest();
+6 -1
View File
@@ -27,7 +27,7 @@ use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub use read::{Structure, read};
pub use read::{Structure, artboards, read};
pub(crate) use read::{slot_frame, slot_of};
#[cfg(test)]
@@ -52,6 +52,11 @@ pub fn eps_data(ps: &[u8]) -> Option<Result<Vec<u8>, String>> {
Some(eps_section(section))
}
/// Does the EPS `ps` carry editing data ([`eps_data`] isn't `None`)?
pub fn has_eps_data(ps: &[u8]) -> bool {
find(ps, EPS_BEGIN).is_some()
}
/// The editing data between the EPS markers: header comments, then the data (encoded or not).
fn eps_section(section: &[u8]) -> Result<Vec<u8>, String> {
let mut header = Vec::new();
+41 -7
View File
@@ -2,8 +2,10 @@
//!
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
//! values its equivalent), `Xx`/`XX` a named colour of either model
//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes.
//! values its equivalent), `Xx`/`XX` a named colour of any model
//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower
//! case paints fills, upper case strokes.
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
@@ -28,6 +30,8 @@ pub(super) struct Named {
/// The full colour.
pub color: Color,
pub tint: f32,
/// An ink of its own (a spot colour), not a global process colour.
pub spot: bool,
}
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
@@ -48,12 +52,13 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
}
"x" => named(vals, Some(false)),
"xx" => named(vals, None),
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
_ => None,
}
}
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
/// the model.
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
/// `rgb` forces RGB or CMYK.
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let name = vals.get(at)?.text()?;
@@ -62,9 +67,14 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let unit = |v: f32| v.clamp(0.0, 1.0);
let tint = 1.0 - unit(*after.first()?);
let n = before.len();
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
let kind = after.get(1).copied();
let color = if rgb.unwrap_or(kind == Some(1.0)) {
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::rgb(unit(*r), unit(*g), unit(*b))
} else if rgb.is_none() && kind == Some(2.0) {
// Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032).
let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
} else {
// The CMYK values come first (an RGB equivalent may follow them).
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
@@ -72,7 +82,7 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
};
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
Some((paint, Some(Named { name, color, tint })))
Some((paint, Some(Named { name, color, tint, spot: true })))
}
/// The colour of a gradient stop's colour values and style.
@@ -162,7 +172,7 @@ pub(super) struct Instance {
hilight: Vec2,
}
fn affine(n: &[f64]) -> Option<Affine> {
pub(super) fn affine(n: &[f64]) -> Option<Affine> {
let [a, b, c, d, e, f] = n else { return None };
let m = Affine::new([*a, *b, *c, *d, *e, *f]);
(m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m)
@@ -247,6 +257,30 @@ mod tests {
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
assert!(color_op("k", &nums(&[1.0])).is_none());
// A global process colour: CMYK (type 0) or RGB (type 1), not an ink.
let mut v = nums(&[0.0, 0.0, 0.0, 1.0]);
v.extend([V::Str(b"K100".to_vec()), V::Num(0.4), V::Num(0.0)]);
let (p, named) = color_op("XK", &v).unwrap();
let named = named.unwrap();
assert_eq!((named.name.as_str(), named.color, named.spot), ("K100", Color::cmyk(0.0, 0.0, 0.0, 1.0), false));
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(
matches!(p, Paint::Solid { swatch: Some(s), tint, color: Color::Cmyk { k, .. } } if s == "K100" && (tint - 0.6).abs() < 1e-6 && (k - 0.6).abs() < 1e-6)
);
let mut v = nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.5, 0.0]);
v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]);
let (p, _) = color_op("Xk", &v).unwrap();
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0)));
// A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032).
let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]);
v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]);
let (p, named) = color_op("Xx", &v).unwrap();
let named = named.unwrap();
assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0));
assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0)));
v[8] = V::Num(0.25);
let (_, named) = color_op("XX", &v).unwrap();
assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75)));
}
#[test]
+188 -25
View File
@@ -2,23 +2,31 @@
//!
//! - Art space has y up; the first artboard's top left corner is the document's origin.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b …
//! dimPercent … Lb`, `(name) Ln`, its art, `LB`.
//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour
//! index), `(name) Ln`, its art, `LB`.
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
//! with `W` clips); a group's transparency and name follow its end.
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
//! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id.
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`.
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else
//! the art size around the template box's centre, else (a file in the legacy format another
//! app wrote) the bounding box.
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
//! and the samples after `XI`.
//! - Art with an appearance the format can't write as plain art (several fills or strokes,
//! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_`
//! lines and `1 (style) XW`: the drawn look is kept, named after the object.
//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's
//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is
//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one:
//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix
//! taking the pattern's space to art space (the other operands are what it is made of).
//!
//! Sections the reader doesn't need (patterns, brushes, symbols, styles, swatches, filters) are
//! skipped; type, symbols, pattern fills, placed files, legacy type and operators it doesn't know
//! are errors.
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it
//! doesn't know are errors.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::rc::Rc;
@@ -30,7 +38,7 @@ use vectorcraft_color::swatch::Swatch;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
Node, NodeId, NodeKind, StrokeLayer,
Node, NodeId, NodeKind, PatternDef, StrokeLayer,
};
use vectorcraft_geom::{FillRule, PathData};
@@ -51,6 +59,10 @@ const MAX_POINTS: usize = 4_000_000;
const MAX_PIXELS: u64 = 1 << 25;
/// Largest side of the document and its artboards (points).
const MAX_SIDE: f64 = 1e6;
/// Art space (y up) → a pattern's space in the document's terms (y down).
const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
/// Most patterns read (others can't fill art).
const MAX_PATTERNS: usize = 4096;
/// What the editing data holds: the document of its art, and where its art space is.
#[derive(Debug)]
@@ -94,6 +106,15 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
r.finish()
}
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
/// as it gets: where to put the pages of its PDF part instead (#1068).
pub fn artboards(data: &[u8]) -> Vec<Rect> {
let mut r = Reader::new(data);
// Artboards read before an error still say where they are.
let _ = r.run();
r.artboards.into_iter().map(|(_, rect)| rect).collect()
}
#[derive(Clone, Debug)]
struct GState {
fill: Paint,
@@ -197,6 +218,16 @@ struct Frame {
clips: Vec<usize>,
}
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
/// before its first operator.
#[derive(Debug, Default)]
struct PatternHead {
taken: bool,
name: String,
/// The tile in the pattern's space (y up).
tile: Option<Rect>,
}
/// An image's operands, from `XN` and before `XI`.
#[derive(Debug, Default)]
struct Raster {
@@ -260,6 +291,10 @@ struct Reader<'a> {
/// The artboard's size and centre in files without artboards or crop marks.
art_size: Option<(f64, f64)>,
template_center: Option<(f64, f64)>,
/// This reader reads a pattern's definition (see [`Reader::define_pattern`]).
pattern: Option<PatternHead>,
/// The tile's top left (y down) of each pattern read, by name.
pattern_tiles: HashMap<String, Point>,
}
/// Operators that change nothing the reader keeps.
@@ -281,6 +316,8 @@ const IGNORED: &[&str] = &[
"Xm",
"XP",
"Np",
// Flatness (`i`): how finely curves print.
"i",
"TE",
"TZ",
"Xs",
@@ -377,6 +414,8 @@ impl<'a> Reader<'a> {
slot_names: HashMap::new(),
art_size: None,
template_center: None,
pattern: None,
pattern_tiles: HashMap::new(),
}
}
@@ -429,6 +468,12 @@ impl<'a> Reader<'a> {
let line = String::from_utf8_lossy(c);
let line = line.trim_end();
let m = marker(line);
// A pattern's definition (one inside another's isn't read).
if m == "BeginPattern" && self.pattern.is_none() {
let section = self.section("EndPattern")?;
self.define_pattern(section);
return Ok(());
}
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
return self.skip_to(end);
}
@@ -481,10 +526,18 @@ impl<'a> Reader<'a> {
/// Skip to the comment with marker `end` (sections of the same kind nested in it count, and
/// versioned content in an alternate).
fn skip_to(&mut self, end: &str) -> Result<(), String> {
self.section(end).map(|_| ())
}
/// [`Self::skip_to`] → what was skipped, up to the comment that ends it.
fn section(&mut self, end: &str) -> Result<&'a [u8], String> {
let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b);
let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin };
let start = self.lex.offset();
let mut depth = 0usize;
while let Some(t) = self.lex.next_token() {
loop {
let before = self.lex.offset();
let Some(t) = self.lex.next_token() else { break };
if let Tok::Comment(c) = t.tok {
let line = String::from_utf8_lossy(c);
let m = marker(&line);
@@ -492,7 +545,7 @@ impl<'a> Reader<'a> {
depth += 1;
} else if m == end {
if depth == 0 {
return Ok(());
return Ok(self.lex.slice(start, before));
}
depth -= 1;
}
@@ -501,6 +554,58 @@ impl<'a> Reader<'a> {
Err(format!("its section ending with `%{end}` doesn't end"))
}
/// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its
/// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't
/// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art
/// filled with it can't be read.
fn define_pattern(&mut self, section: &[u8]) {
if self.pattern_tiles.len() >= MAX_PATTERNS {
return;
}
let mut sub = Reader::new(section);
sub.pattern = Some(PatternHead::default());
// The gradients defined so far, lent (and those it defines kept).
sub.gradients = std::mem::take(&mut self.gradients);
sub.nodes = self.nodes;
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
self.nodes = sub.nodes;
self.gradients = std::mem::take(&mut sub.gradients);
let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1;
let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return };
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
return;
};
let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
let mut def = PatternDef::new(&head.name, art);
def.tile = FLIP.transform_rect_bbox(tile);
if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() {
self.doc.patterns.retain(|p| p.name != def.name);
}
self.doc.patterns.push(def);
for (key, blob) in sub.doc.images {
self.doc.images.entry(key).or_insert(blob);
}
self.spots.extend(sub.spots);
self.cmyk_colors += sub.cmyk_colors;
self.rgb_colors += sub.rgb_colors;
for w in &sub.warnings {
self.warn(w);
}
}
/// In a pattern's definition, the operands of its first operator start with its head,
/// `(name) llx lly urx ury`: taken off them.
fn take_head(&mut self, mut vals: Vec<V>) -> Vec<V> {
let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals };
head.taken = true;
let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals };
let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default());
let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals };
head.name = name;
head.tile = Some(Rect::new(*x0, *y0, *x1, *y1));
vals.split_off(at + 5)
}
// ---- frames ----------------------------------------------------------------------------
fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> {
@@ -682,6 +787,7 @@ impl<'a> Reader<'a> {
self.after_object = false;
}
let vals = self.operands();
let vals = self.take_head(vals);
if let Some(d) = self.gradient_def.as_mut() {
match w {
"Bs" => d.stop(&vals, &mut self.spots),
@@ -728,8 +834,10 @@ impl<'a> Reader<'a> {
self.paint(&op, hidden, true)?;
}
// Colours.
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" => self.color(w, &vals),
"p" | "P" => self.unreadable("pattern fills"),
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals),
"p" | "P" => self.pattern_paint(w, &vals),
// A pattern's definition ends.
"E" if self.pattern.is_some() => self.done = true,
"O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0),
"R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0),
"XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero },
@@ -856,6 +964,9 @@ impl<'a> Reader<'a> {
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
fn text_slot(&mut self, o: &Obj) -> Result<(), String> {
if self.pattern.is_some() {
self.unreadable("type in a pattern");
}
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
let gs = self.gs.clone();
@@ -893,7 +1004,7 @@ impl<'a> Reader<'a> {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
}
let target = self.ensure_space().transform_rect_bbox(art_box);
let target = self.space().transform_rect_bbox(art_box);
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
Ok(r) => r,
@@ -1001,7 +1112,8 @@ impl<'a> Reader<'a> {
}
}
/// `… Lb`: a layer's options.
/// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place
/// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`).
fn layer_attrs(&mut self, vals: &[V]) {
let n = Self::nums(vals);
let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return };
@@ -1012,15 +1124,44 @@ impl<'a> Reader<'a> {
a.printable = flag(3, true);
let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
let index = n.get(7).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
let at = if n.len() <= 10 { 6 } else { 7 };
let index = n.get(at).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
LayerColor::Preset(index as u8)
} else {
LayerColor::Custom([rgb(8), rgb(9), rgb(10)])
LayerColor::Custom([rgb(1), rgb(2), rgb(3)])
});
}
/// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's):
/// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't
/// read can't be drawn.
fn pattern_paint(&mut self, w: &str, vals: &[V]) {
// In the setup it paints nothing (and the art's space isn't fixed yet).
if self.frames.is_empty() {
return;
}
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else {
self.unreadable("pattern fills");
return;
};
let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None });
// The tile's art (y down from the tile's top left) → the pattern's space → art space.
let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin);
if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) {
self.unreadable("pattern fills");
return;
}
let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf };
if w == "p" {
self.gs.fill = p;
} else {
self.gs.stroke = p;
}
}
fn color(&mut self, w: &str, vals: &[V]) {
let Some((p, named)) = paint::color_op(w, vals) else { return };
match p.color() {
@@ -1031,7 +1172,7 @@ impl<'a> Reader<'a> {
if let Some(n) = named {
self.spots.push(n);
}
if w.chars().all(|c| c.is_ascii_lowercase()) || w == "Xa" || w == "Xx" {
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
self.gs.fill = p;
} else {
self.gs.stroke = p;
@@ -1165,7 +1306,7 @@ impl<'a> Reader<'a> {
if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
close(&mut bp);
}
let to_doc = self.ensure_space();
let to_doc = self.space();
let path = PathData::from_bezpath(&(to_doc * bp));
let gs = self.gs.clone();
let (fill, stroke) = match op.to_ascii_lowercase().as_str() {
@@ -1200,7 +1341,7 @@ impl<'a> Reader<'a> {
}
fn set_instance(&mut self, i: &Instance) {
let to_doc = self.ensure_space();
let to_doc = self.space();
match i.paint(&self.gradients, to_doc) {
Some(p) if i.stroke => self.pending_stroke = Some(p),
Some(p) => self.pending_fill = Some(p),
@@ -1286,9 +1427,8 @@ impl<'a> Reader<'a> {
}
// The matrix maps pixels (y down) into art space with its y flipped.
let [a, b, c, d, tx, ty] = m.as_slice() else { return Ok(()) };
let to_doc = self.ensure_space();
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let xf = to_doc * flip * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
let to_doc = self.space();
let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
return Ok(());
}
@@ -1314,18 +1454,36 @@ impl<'a> Reader<'a> {
(sane(r) && w > 0.0 && h > 0.0).then_some(r)
}
/// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`].
fn first_board(&self) -> Option<Rect> {
self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board())
}
/// The page of a file that says nothing of its artboard (the legacy format as other apps write
/// it): its art's bounding box.
fn bbox_board(&self) -> Option<Rect> {
let [x0, y0, x1, y1] = self.hires.or(self.bbox)?;
Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0)
}
/// Art space → the document (fixed when the art starts, from the artboards read by then).
fn ensure_space(&mut self) -> Affine {
if let Some(m) = self.to_doc {
return m;
}
let first = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
let first = self.first_board().or_else(|| self.bbox_board());
let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1));
let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]);
self.to_doc = Some(m);
m
}
/// Art space → where the art being read goes: the document, or while a pattern's definition is
/// read, the pattern's space (y down).
fn space(&mut self) -> Affine {
if self.pattern.is_some() { FLIP } else { self.ensure_space() }
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 {
self.warnings.push(w.to_string());
@@ -1345,11 +1503,11 @@ impl<'a> Reader<'a> {
return Err("it has no layers".into());
}
let to_doc = self.ensure_space();
let artboard = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
let artboard = self.first_board();
drop_commented_copies(&mut self.doc.layers, &self.hidden_ids);
let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0));
let boards: Vec<(String, Rect)> = if self.artboards.is_empty() {
self.cropmarks.or_else(|| self.sized_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
} else {
self.artboards.clone()
};
@@ -1374,7 +1532,12 @@ impl<'a> Reader<'a> {
}
for n in &self.spots {
if doc.swatch(&n.name).is_none() {
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: true });
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot });
}
}
for name in doc.patterns.iter().map(|p| p.name.clone()).collect::<Vec<_>>() {
if doc.swatch(&name).is_none() {
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
}
}
let mut warnings = self.warnings;
+86
View File
@@ -132,6 +132,92 @@ fn paths_colours_and_strokes() {
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
}
/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as
/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint.
#[test]
fn global_process_colours_are_global_swatches_at_their_tint() {
let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0));
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(swatch.as_deref(), Some("Black global"));
assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}");
let stroke = a[0].appearance.stroke().unwrap().paint.clone();
assert!(
matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)),
"{stroke:?}"
);
for name in ["Black global", "Brand orange"] {
let sw = doc.swatch(name).unwrap();
assert!(sw.global && !sw.spot, "{sw:?}");
}
assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour");
}
/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up),
/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped
/// stroke of no length), `E`.
const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\
%_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n";
/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its
/// layers, the fill places the tiles by its matrix.
#[test]
fn patterns_are_pattern_swatches_and_fill_with_them() {
let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n");
let body = format!(
"{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n",
sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))),
sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))),
);
let s = read_ok(&body);
let doc = &s.doc;
let ground = &doc.layers[0];
let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(subs, ["Lawn", "Paving"]);
let def = doc.pattern("Dots").unwrap();
assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down");
assert_eq!(def.art.len(), 1, "the tile's group");
let tile_box = &def.art[0].children().unwrap()[0];
assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is");
assert_eq!(def.art[0].children().unwrap().len(), 2);
assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots"));
let lawn = &ground.children().unwrap()[0].children().unwrap()[0];
let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) };
assert_eq!(pattern, "Dots");
// The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot
// centre (4, 4) is at (4, 96).
assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0));
assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0));
// A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in
// art space.
let paving = &ground.children().unwrap()[1].children().unwrap()[0];
let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() };
assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
}
/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file
/// has that isn't read, where it shows. Its definition costs nothing else.
#[test]
fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() {
let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\
%AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n";
let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. })));
for name in ["Old", "Odd", "Flat"] {
let e = read(
stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))))
.as_bytes(),
)
.unwrap_err();
assert!(e.contains("pattern fills"), "{name}: {e}");
}
// Type in a pattern isn't read either.
let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n";
assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty());
}
#[test]
fn compound_paths_and_clipping() {
let art_ = format!(
+18 -11
View File
@@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 {
if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d }
}
/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left
/// of each after whole turns.
fn ccw_turn(a: f64, b: f64) -> f64 {
(b % 360.0 - a % 360.0).rem_euclid(360.0)
}
impl Interp<'_> {
/// User space → document space.
pub fn xf(&self) -> Affine {
@@ -436,18 +442,19 @@ impl Interp<'_> {
/// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise).
fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res {
let (a1, mut a2) = (a1.to_radians(), a2.to_radians());
let tau = std::f64::consts::TAU;
if ccw {
while a2 < a1 {
a2 += tau;
}
// `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it
// until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps,
// and the first endpoint, are worked out from what is left of each angle after whole turns.
let sweep = if ccw && a2 < a1 {
ccw_turn(a1, a2)
} else if !ccw && a2 > a1 {
-ccw_turn(a2, a1)
} else {
while a2 > a1 {
a2 -= tau;
}
}
let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0);
a2 - a1
};
let tau = std::f64::consts::TAU;
let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0);
let a1 = (a1 % 360.0).to_radians();
let start = c + Vec2::new(r * a1.cos(), r * a1.sin());
self.append_user(std::iter::once(PathEl::MoveTo(start)))?;
let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0);
+17 -6
View File
@@ -16,14 +16,18 @@
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
//! as groups. The format's published specification documents its header comments (`%AI…`,
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423.
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
//! an Illustrator EPS's editing copy (#1027).
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its
//! preview (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
//! error is kept (with that warning), else the file is refused.
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an
//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in;
//! without a preview, what was drawn before the error is kept (with that warning), else the file
//! is refused.
mod ai;
mod ate;
@@ -201,6 +205,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
(Err(e), _) => reason(e, fault.as_ref()),
};
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
// prolog defining its operators without including it): its layers, which the program has.
let why = match editing_data.then(|| native::program_alone(ps)).flatten() {
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
None => why,
};
if let Some(p) = preview::preview(bytes, ps, frame) {
let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)];
return Ok(Imported { document: p.document, warnings, preview: true });
@@ -214,7 +225,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
out.warn("the file draws nothing");
Ok(finish(out))
}
Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))),
Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")),
}
}
+156 -10
View File
@@ -1,11 +1,15 @@
//! The layers of an Illustrator EPS or `.ai`.
//!
//! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the
//! page doesn't: the layers with their names and options, the groups, compound paths and clipping
//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and
//! every artboard. `ai` reads that structure into a document, with the colours, gradients,
//! transparency and images of the art.
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). A file in the
//! legacy format (a `.ai` in PostScript form: versions 3 to 8, and the apps that still write it)
//! is that copy itself: its program has the layers, and the prolog it names defines the operators
//! for printing (other apps name it without including it, so their files can't be run).
//!
//! The copy has what the page doesn't: the layers with their names and options, the groups,
//! compound paths and clipping groups, the objects' names, the hidden objects and layers, the art
//! outside the artboards and every artboard. `ai` reads that structure into a document, with the
//! colours, gradients, transparency and images of the art.
//!
//! Text objects are slots in that document: their characters, fonts and places are in the file's
//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot
@@ -21,6 +25,7 @@
//!
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::Arc;
@@ -619,6 +624,17 @@ fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL)
}
/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour
/// and its CMYK equivalent stay within it; a red and a dark red don't).
const SAME_COLOUR: u32 = 64;
/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same
/// lightness to be alike)?
fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
let (p, q) = (on_white(p), on_white(q));
p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR)
}
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of
/// those whose neighbours all differ too, away from type (where one draws an object that the other
/// doesn't, not the edges of one drawn a little differently, or type laid out a little
@@ -667,6 +683,20 @@ impl PageView {
type_areas(&b.layers, rb, self.scale, &mut areas);
compared(&self.page, &y, &areas)
}
/// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page
/// ([`differ_in_colour`]), type included: what taking an object out of the layers changes
/// there, when it is covered by one of about the same lightness (#1102).
fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 {
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let y = self.layers.render_region(b, rb, self.scale, false);
if (y.width, y.height) != (self.page.width, self.page.height) {
return 1.0;
}
let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count();
differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0)
}
}
/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]),
@@ -818,6 +848,14 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
let artboard_page =
artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0));
let whole = matches!(page, Page::Art) && !artboard_page;
if whole {
// Its page is the file's own (its bounding box), as when it comes in as its page: art
// reaching past the editing data's artboard stays on it (#1102).
doc.artboards.truncate(1);
if let Some(a) = doc.artboards.first_mut() {
a.rect = rect;
}
}
frame = Some(Frame { page: visible, page_rect, rect, whole });
}
let mut infos = vec![];
@@ -935,7 +973,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
if found.is_empty() || found.len() > MAX_WEIGHED {
return 0;
}
let mut with = view.difference(doc, rect).any;
let with = view.difference(doc, rect).any;
// Taking a text object out changes only what is drawn in its box. When the art would still
// differ from the page by more than `compare` allows with every candidate gone, the layers
// aren't used whatever is pruned: don't draw them again for each one (#758).
@@ -944,6 +982,9 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE {
return 0;
}
// Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no
// less there for being about as light (#1102).
let mut with = view.colour_difference(doc, rect);
let mut removed = 0;
for (id, bounds) in found {
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
@@ -953,7 +994,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
if !take_out(&mut without.layers, id, true) {
continue;
}
let d = view.difference(&without, rect).any;
let d = view.colour_difference(&without, rect);
if d - with <= UNSEEN_MARGIN {
take_out(&mut doc.layers, id, false);
with = d;
@@ -991,12 +1032,13 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
}))
}
/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the
/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or
/// its program in the legacy format, see [`editing_copy`]): its layers (the
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
/// has one that can't be used, `visible` with a warning that says why.
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
let Some(data) = ai::eps_data(ps) else { return visible };
let Some(data) = editing_copy(ps) else { return visible };
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false));
match result {
Ok((document, mut warnings)) => {
@@ -1023,7 +1065,7 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
/// its PDF part).
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>) {
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
if !has_layers(&data) {
return (visible, warnings);
}
match build(&data, Some(&visible), Page::Artboard, outlined) {
@@ -1035,11 +1077,53 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
Err(why) => {
let mut all = warnings;
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
let mut visible = visible;
place_pages(&ai::artboards(&data), &mut visible);
(visible, all)
}
}
}
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
/// there are as many artboards as pages, of the same sizes.
fn place_pages(boards: &[Rect], doc: &mut Document) {
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
return;
}
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
for layer in &mut doc.layers {
place_art(Arc::make_mut(layer), &pages, &moves);
}
for (a, v) in doc.artboards.iter_mut().zip(&moves) {
a.rect = a.rect + *v;
}
}
/// The objects in `layer` (and its sublayers) moved by the move of the page they are on.
fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
let Some(children) = layer.children_mut() else { return };
for child in children {
if matches!(child.kind, NodeKind::Layer { .. }) {
place_art(Arc::make_mut(child), pages, moves);
continue;
}
let Some(r) = child.visual_bounds() else { continue };
// The page it is on: the one with most of it (pages keep only the art that reaches into
// them), else the nearest.
let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot());
let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue };
if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) {
Arc::make_mut(child).transform(Affine::translate(*v), false);
}
}
}
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
@@ -1047,11 +1131,73 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
build(&data, None, Page::Artboard, false)
}
/// Does the editing data (or a program in the legacy format) have layers?
fn has_layers(data: &[u8]) -> bool {
data.windows(15).any(|w| w == b"%AI5_BeginLayer")
}
/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program
/// itself when it has layers (the legacy format). `None` without one.
fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
match ai::eps_data(ps) {
Some(data) => Some(data.map(Cow::Owned)),
None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))),
}
}
/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its
/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the
/// document and the import's notes. `None` when the layers aren't in the program.
pub(super) fn program_alone(ps: &[u8]) -> Option<Result<(Document, Vec<String>), String>> {
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::{CharStyle, TextObject};
/// `doc` with a square ring round (20, 20)–(80, 80) (or at `at`), stroked `color` `width` wide,
/// named `name`.
fn ring(doc: &mut Document, name: Option<&str>, color: vectorcraft_color::Color, width: f64, at: Rect) {
let stroke = vectorcraft_color::Paint::solid(color);
let mut n = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(at),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, stroke, width),
);
n.name = name.map(str::to_string);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
/// A text object's red stroke drawn over a dark red one of about the same lightness is drawn
/// by the page: it isn't taken out as unseen (#1102). An object the page doesn't draw still is.
#[test]
fn a_stroke_over_one_as_light_is_not_taken_for_unseen() {
let (dark, red) = (vectorcraft_color::Color::rgb(0.55, 0.0, 0.0), vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
let square = Rect::new(20.0, 20.0, 80.0, 80.0);
let mut page = Document::new(100.0, 100.0);
ring(&mut page, None, dark, 12.0, square);
ring(&mut page, None, red, 6.0, square);
let mut layers = Document::new(100.0, 100.0);
ring(&mut layers, Some(MADE), dark, 12.0, square);
ring(&mut layers, Some(MADE), red, 6.0, square);
ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0));
let rect = page.artboards[0].rect;
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let mut view = PageView::new(&frame);
assert_eq!(prune_unseen(&frame, &mut view, &mut layers), 1, "only the ring the page doesn't draw");
let widths: Vec<f64> = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
assert_eq!(widths, [12.0, 6.0]);
// Taken by lightness alone, the red and the dark red are alike.
let px = |c: vectorcraft_color::Color| {
let [r, g, b] = c.to_rgb().map(|v| (v * 255.0).round() as u8);
[r, g, b, 255]
};
assert!(!differ(&px(dark), &px(red)) && differ_in_colour(&px(dark), &px(red)));
}
fn piece(text: &str, x: f64, y: f64) -> Arc<Node> {
Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default())))))
}
+36
View File
@@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() {
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
}
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
/// more than 20 s.
fn read_soon(body: &str) -> crate::Imported {
let (tx, rx) = std::sync::mpsc::channel();
let ps = eps(body);
std::thread::spawn(move || tx.send(import(&ps)).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
}
/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn`
/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however
/// large the angles: the arc runs between the angles' remainders after whole turns and ends at
/// the second angle, the new current point.
#[test]
fn arcs_with_huge_angles_end_at_their_second_angle() {
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
// document space.
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
// Each program is listed with the remainders of its first and second angles after whole turns.
for (body, first, second) in [
("50 50 10 1e18 0 arc", 280.0, 0.0),
("50 50 10 1e308 0 arc", 296.0, 0.0),
("50 50 10 0 1e18 arcn", 0.0, 280.0),
("50 50 10 0 1e308 arcn", 0.0, 296.0),
] {
let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
let o = objects(&r.document);
assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings);
// The arc lies within one quarter of the circle, so its endpoints span its bounds.
let (a, b) = (at(first), at(second));
assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0]));
// The second object is the square drawn at the current point.
assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1]));
}
}
#[test]
fn cshow_runs_its_procedure_for_each_character() {
check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq");
+76 -1
View File
@@ -4,7 +4,8 @@
use std::sync::Arc;
use vectorcraft_doc::clipnest::MAX_NEST;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind};
use vectorcraft_geom::Rect;
use crate::import::import;
@@ -80,6 +81,80 @@ fn other_postscript_keeps_its_own_u() {
assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p");
}
/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the
/// procsets that define its operators (by the DSC resource comments) without including them, so
/// its program can't be run. Its layers come from the program itself: names as they are, the
/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page.
const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\
%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\
%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\
Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\
%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\
10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n";
#[test]
fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() {
let r = import(NAMED_PROLOG.as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let d = &r.document;
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]);
let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect();
assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box");
let square = &d.layers[0].children().unwrap()[0];
assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0)));
let stroke = square.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round));
assert_eq!(tree(d.layers[1].children().unwrap()), "p");
}
/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its
/// program.
#[test]
fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n");
let e = import(damaged.as_bytes()).unwrap_err();
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
}
/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup):
/// it opens with its sublayers.
#[test]
fn newer_editing_text_as_postscript_opens_with_its_sublayers() {
let layer =
|name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n");
let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", "")));
let text = format!(
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n"
);
let r = import(text.as_bytes()).unwrap();
let d = &r.document;
assert_eq!(d.layers.len(), 1, "{:?}", r.warnings);
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(subs, ["Lawn", "Paving"]);
}
/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and
/// comes in through the layers its program has when they look like the page.
#[test]
fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() {
let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def";
let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10));
let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog"));
let r = import(file.as_bytes()).unwrap();
let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Back"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// Without the editing data, it is its page.
let page = crate::import::import_with(file.as_bytes(), false).unwrap();
assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1"));
}
#[test]
fn unbalanced_and_runaway_groups_are_read_safely() {
// Ends without a beginning end nothing; groups left open hold the rest.
+84
View File
@@ -237,6 +237,49 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
}
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
/// pages still go where its editing data puts its artboards, with their art (#1068).
#[test]
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
// Two 100-point artboards, the second below the first (art space, y up).
let board = |x0: i32, y0: i32| {
format!(
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
y0 + 100,
x0 + 100
)
};
let boards = |list: &[(i32, i32)]| {
let all: String = list.iter().map(|(x, y)| board(*x, *y)).collect();
format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{all}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n")
};
// An operator this doesn't know, so the layers aren't read.
let unread = |list: &[(i32, i32)]| native(&(boards(list) + &layer("Art", true, &(square(10, 10) + "\n1 Zz"))));
// The PDF part: the pages side by side, 20 points apart, a square on each.
let mut pdf = pdf_part(10.0, 10.0);
let second = pdf_part(130.0, 10.0).layers[0].children().unwrap()[0].clone();
let mut second = (*second).clone();
second.id = pdf.alloc_id();
let layer_id = pdf.layers[0].id;
pdf.insert(Some(layer_id), 1, second).unwrap();
let mut page = pdf.artboards[0].clone();
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
pdf.artboards.push(page);
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
let moved = squares(&d);
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
// Artboards that don't match the pages leave them in a row.
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
}
}
#[test]
fn a_gray_is_a_grayscale_colour() {
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
@@ -543,6 +586,33 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
}
/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its
/// PDF part, which has only the top-level layers (#1025).
#[test]
fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
// The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden
// sublayer with a pattern fill (the page doesn't have it).
let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf";
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
let art = native(&format!("{dots}{ground}"));
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot));
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
// Without a PDF part, the pattern fill shows.
let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned))));
let (d, notes) = crate::ai_alone(&private(&shown)).unwrap();
let lawn = &d.layers[0].children().unwrap()[0];
assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}");
let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone());
assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}");
}
#[test]
fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
// A 20-point square that the page doesn't draw: 4% of the page, and an object all the same.
@@ -584,6 +654,20 @@ fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_p
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// An EPS whose page is its art's box keeps that page when it comes in through its layers, as it
/// does as its page: not the editing data's artboard (#1102).
#[test]
fn an_eps_page_of_its_art_stays_its_page_through_the_layers() {
let file = eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + &layer("Far", false, &square(80, 80))));
let r = import(&file).unwrap();
assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings);
let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect();
let art = r.document.layers[0].children().unwrap()[0].visual_bounds().unwrap();
assert_eq!(boards.len(), 1);
assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}");
assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}");
}
#[test]
fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() {
// The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's.
+183 -44
View File
@@ -1,7 +1,8 @@
//! Live Corners on any path: the anchors where two straight sides meet at an angle, and the path
//! with those corners cut round, inverted round or chamfered. Rectangles cut theirs the same way.
//! Live Corners on any path: the anchors where two sides, straight or curved, meet at an angle,
//! and the path with those corners cut round, inverted round or chamfered (a curved side trimmed
//! to what the cut leaves of it). Rectangles cut theirs the same way.
use kurbo::{Point, Vec2};
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, Point, Vec2};
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
use crate::shapes::{CornerKind, KAPPA};
@@ -11,39 +12,91 @@ const EPS: f64 = 1e-9;
/// Sides closer than this to straight on (or folded back), as the sine of their angle, make no corner.
const MIN_SINE: f64 = 1e-6;
/// A corner Live Corners can cut: an anchor without handles between two straight sides that
/// meet at an angle.
/// How closely arc lengths along curved sides are measured (points).
const ARCLEN_ACCURACY: f64 = 1e-6;
/// A side of a corner: the segment from the corner's anchor to its neighbour, as a cubic leaving
/// the corner (a straight side has its handles at its ends).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Side {
pub curve: CubicBez,
}
impl Side {
/// Is the side straight, its handles on its ends?
fn straight(&self) -> bool {
self.curve.p1.distance(self.curve.p0) <= EPS && self.curve.p2.distance(self.curve.p3) <= EPS
}
/// The unit direction the side leaves the corner in: towards its first control point not on
/// the corner. `None` when the side has no length.
fn direction(&self) -> Option<Vec2> {
let c = self.curve;
let d = [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > EPS)?;
let d = d / d.hypot();
d.x.is_finite().then_some(d)
}
/// The side's length.
fn length(&self) -> f64 {
if self.straight() { self.curve.p0.distance(self.curve.p3) } else { self.curve.arclen(ARCLEN_ACCURACY) }
}
/// The parameter `s` along the side from the corner, the point there and the direction the
/// side runs on in (away from the corner).
fn at_length(&self, s: f64, u: Vec2) -> (f64, Point, Vec2) {
let c = self.curve;
if self.straight() {
let len = c.p0.distance(c.p3);
let t = if len > EPS { (s / len).clamp(0.0, 1.0) } else { 0.0 };
return (t, c.p0 + u * s, u);
}
let t = c.inv_arclen(s, ARCLEN_ACCURACY).clamp(0.0, 1.0);
let d = c.deriv().eval(t).to_vec2();
let dir = if d.hypot() > EPS { d / d.hypot() } else { u };
(t, c.eval(t), dir)
}
}
/// A corner Live Corners can cut: an anchor that isn't smooth, where two sides (straight or
/// curved) meet at an angle (#1144).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Corner {
/// The anchor's index in its path, counting the anchors of every subpath in order.
pub index: usize,
/// Where the sides meet.
pub at: Point,
/// Unit directions along the sides: towards the previous anchor, then towards the next one.
/// Unit directions the sides leave the corner in: towards the previous anchor, then towards
/// the next one.
pub u: Vec2,
pub v: Vec2,
/// The sides' lengths, in the same order.
/// The sides' lengths (along curves), in the same order.
pub lu: f64,
pub lv: f64,
/// The sides themselves, leaving the corner, in the same order.
pub side_u: Side,
pub side_v: Side,
}
impl Corner {
/// The corner of anchor `i` of `sp`, whose index in the path is `index`; `None` when the
/// anchor is an end of an open subpath, has handles or is smooth, when a side curves or has
/// no length, or when the sides run straight on.
/// anchor is an end of an open subpath or is smooth, when a side has no length, or when the
/// sides leave it straight on.
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
let n = sp.anchors.len();
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
if a.kind == AnchorKind::Smooth || a.has_in() || a.has_out() || prev.has_out() || next.has_in() {
if a.kind == AnchorKind::Smooth {
return None;
}
let (du, dv) = (prev.p - a.p, next.p - a.p);
let (lu, lv) = (du.hypot(), dv.hypot());
let side_u = Side { curve: CubicBez::new(a.p, a.h_in, prev.h_out, prev.p) };
let side_v = Side { curve: CubicBez::new(a.p, a.h_out, next.h_in, next.p) };
let (u, v) = (side_u.direction()?, side_v.direction()?);
let (lu, lv) = (side_u.length(), side_v.length());
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
return None;
}
let c = Self { index, at: a.p, u: du / lu, v: dv / lv, lu, lv };
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v };
(c.sin() > MIN_SINE).then_some(c)
}
@@ -92,32 +145,47 @@ impl Corner {
d.dot(self.u + self.v) * self.sin() / (2.0 + 2.0 * self.cos())
}
/// The two anchors replacing the corner when cut with `radius` (> 0, fitted): where the cut
/// meets the arriving side, then the leaving one.
fn cut(&self, radius: f64, kind: CornerKind) -> [Anchor; 2] {
let t = self.setback(radius);
let (start, end) = (self.at + self.u * t, self.at + self.v * t);
/// The cut of radius `radius` (> 0, fitted): where it meets each side, `setback` along it,
/// and the cut's own curve between them, its handles leaving the sides smoothly.
fn cut(&self, radius: f64, kind: CornerKind) -> Cut {
let s = self.setback(radius);
let (tu, start, du) = self.side_u.at_length(s, self.u);
let (tv, end, dv) = self.side_v.at_length(s, self.v);
let cos = du.dot(dv);
let (h_out, h_in) = match kind {
// An arc of `radius` tangent to both sides, turning by 180° less the angle.
// An arc tangent to both sides, turning by 180° less the angle between them there.
CornerKind::Round => {
let h = radius * arc_handle(-self.cos());
(start - self.u * h, end - self.v * h)
let h = radius * arc_handle(-cos);
(start - du * h, end - dv * h)
}
// An arc centred on the corner through both ends, spanning the angle.
CornerKind::InvertedRound => {
let h = t * arc_handle(self.cos());
(start + square_to(self.u, self.v) * h, end + square_to(self.v, self.u) * h)
let h = s * arc_handle(cos);
(start + square_to(du, dv) * h, end + square_to(dv, du) * h)
}
CornerKind::Chamfer => (start, end),
};
[Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner }, Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner }]
Cut { tu, tv, start, h_out, h_in, end }
}
}
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
/// and the handles between them.
#[derive(Clone, Copy, Debug)]
struct Cut {
tu: f64,
tv: f64,
start: Point,
h_out: Point,
h_in: Point,
end: Point,
}
/// The unit direction square to unit `a`, on `b`'s side.
fn square_to(a: Vec2, b: Vec2) -> Vec2 {
let w = b - a * a.dot(b);
w / w.hypot()
let l = w.hypot();
if l > EPS { w / l } else { Vec2::ZERO }
}
/// The handle length, per unit of radius, of a cubic drawing a circular arc that spans an angle
@@ -126,6 +194,7 @@ fn arc_handle(cos: f64) -> f64 {
if cos.abs() < 1e-12 {
return KAPPA;
}
let cos = cos.clamp(-1.0, 1.0);
let (sin_half, cos_half) = (((1.0 - cos) / 2.0).max(0.0).sqrt(), ((1.0 + cos) / 2.0).max(0.0).sqrt());
4.0 / 3.0 * sin_half / (1.0 + cos_half)
}
@@ -143,24 +212,55 @@ pub fn path_corners(path: &PathData) -> Vec<Corner> {
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
/// ([`Corner::fitted`]). A cut corner becomes two anchors; a closed subpath whose first corner
/// is cut starts where that cut ends, so the cut closes it. Also returns, per subpath, the
/// anchor index in `path` each anchor comes from.
/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is
/// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that
/// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each
/// anchor comes from.
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
let corners = path_corners(path);
let mut corners = corners.iter().peekable();
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
let mut first = 0;
for sp in &path.subpaths {
let (mut anchors, mut from) = (Vec::with_capacity(sp.anchors.len()), Vec::with_capacity(sp.anchors.len()));
let n = sp.anchors.len();
// The cut of each anchor, if any.
let cuts: Vec<Option<Cut>> = (0..n)
.map(|i| {
let index = first + i;
let c = corners.next_if(|c| c.index == index)?;
let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0));
(radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default()))
})
.collect();
// Each segment from anchor j, trimmed to the part the cuts at its ends leave: its handles
// (out of its first anchor, into its last), `None` when it is untouched.
let trimmed: Vec<Option<(Point, Point)>> = (0..n)
.map(|j| {
let k = (j + 1) % n;
let (a, b) = (sp.anchors.get(j)?, sp.anchors.get(k)?);
let (from, to) = (cuts.get(j).copied().flatten(), cuts.get(k).copied().flatten());
if (from.is_none() && to.is_none()) || (k == 0 && !sp.closed) {
return None;
}
// A straight segment stays straight, its handles on its ends.
if !a.has_out() && !b.has_in() {
return Some((from.map_or(a.p, |c| c.end), to.map_or(b.p, |c| c.start)));
}
let (s0, s1) = (from.map_or(0.0, |c| c.tv), to.map_or(1.0, |c| 1.0 - c.tu));
let sub = CubicBez::new(a.p, a.h_out, b.h_in, b.p).subsegment(s0..s1.max(s0));
Some((sub.p1, sub.p2))
})
.collect();
let (mut anchors, mut from) = (Vec::with_capacity(n), Vec::with_capacity(n));
let mut closing = None;
for (i, a) in sp.anchors.iter().enumerate() {
let index = first + i;
let corner = corners.next_if(|c| c.index == index);
let radius = corner.map_or(0.0, |c| c.fitted(radii.get(index).copied().unwrap_or(0.0)));
match corner {
Some(c) if radius > EPS => {
let [start, end] = c.cut(radius, kinds.get(index).copied().unwrap_or_default());
let h_in = trimmed.get((i + n - 1) % n).copied().flatten().map_or(a.h_in, |(_, h)| h);
let h_out = trimmed.get(i).copied().flatten().map_or(a.h_out, |(h, _)| h);
match cuts.get(i).copied().flatten() {
Some(c) => {
let start = Anchor { p: c.start, h_in, h_out: c.h_out, kind: AnchorKind::Corner };
let end = Anchor { p: c.end, h_in: c.h_in, h_out, kind: AnchorKind::Corner };
if i == 0 && sp.closed {
closing = Some(start);
} else {
@@ -170,8 +270,8 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
anchors.push(end);
from.push(index);
}
_ => {
anchors.push(*a);
None => {
anchors.push(Anchor { h_in, h_out, ..*a });
from.push(index);
}
}
@@ -180,7 +280,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
anchors.push(a);
from.push(first);
}
first += sp.anchors.len();
first += n;
subpaths.push(SubPath::new(anchors, sp.closed));
sources.push(from);
}
@@ -246,11 +346,7 @@ mod tests {
false,
));
assert_eq!(path_corners(&open).iter().map(|c| c.index).collect::<Vec<_>>(), [2, 3]);
// A curve on either side, or handles on the anchor, leave it alone.
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
sp.anchors[1].h_out = Point::new(110.0, 10.0);
let p = PathData::single(sp);
assert_eq!(path_corners(&p).iter().map(|c| c.index).collect::<Vec<_>>(), [0, 3]);
// Smooth anchors aren't corners.
assert!(path_corners(&shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))).is_empty());
// A second subpath counts on from the first one's anchors.
let two = PathData::new(vec![
@@ -262,6 +358,49 @@ mod tests {
assert_eq!(cut.anchor_count(), 7);
}
/// The square `0..100` with its right side bulging out as a curve (#1144).
fn bulging_square() -> PathData {
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
sp.anchors[1].h_out = Point::new(140.0, 30.0);
sp.anchors[2].h_in = Point::new(140.0, 70.0);
PathData::single(sp)
}
/// Corners where a curve meets a line are cut too: the cut ends on the path, leaves both
/// sides smoothly, and what is left of the curve still follows it (#1144).
#[test]
fn corners_next_to_curves_are_cut_along_them() {
let path = bulging_square();
let corners = path_corners(&path);
assert_eq!(corners.iter().map(|c| c.index).collect::<Vec<_>>(), [0, 1, 2, 3]);
let side = kurbo::CubicBez::new(Point::new(100.0, 0.0), Point::new(140.0, 30.0), Point::new(140.0, 70.0), Point::new(100.0, 100.0));
// The curved side is longer than its chord, and limits its corners by its length.
assert!(corners[1].lv > 100.0 + 1.0);
let (cut, from) = cut_corners(&path, &[10.0; 4], &[]);
assert_eq!(from, [vec![0, 1, 1, 2, 2, 3, 3, 0]]);
let sp = &cut.subpaths[0];
let on_side = |p: Point| (0..=1000).map(|i| side.eval(f64::from(i) / 1000.0).distance(p)).fold(f64::MAX, f64::min);
// Corner 1's cut ends on the curve and corner 2's starts on it, and the trimmed curve
// between them stays on the original one.
let (a, b) = (sp.anchors[2], sp.anchors[3]);
assert!(on_side(a.p) < 0.1 && on_side(b.p) < 0.1);
let trimmed = kurbo::CubicBez::new(a.p, a.h_out, b.h_in, b.p);
for t in [0.25, 0.5, 0.75] {
assert!(on_side(trimmed.eval(t)) < 0.1);
}
// Every cut leaves its sides smoothly: each anchor's handles are in line.
for (i, x) in sp.anchors.iter().enumerate() {
let (into, out) = if x.has_in() && x.has_out() {
(x.p - x.h_in, x.h_out - x.p)
} else if x.has_out() {
(x.p - sp.anchors[(i + sp.anchors.len() - 1) % sp.anchors.len()].p, x.h_out - x.p)
} else {
(x.h_in - x.p, x.p - sp.anchors[(i + 1) % sp.anchors.len()].p)
};
assert!(into.normalize().cross(out.normalize()).abs() < 1e-6 && into.dot(out) > 0.0, "anchor {i}");
}
}
/// Inverted round and chamfer cuts end where the round one does, at any angle.
#[test]
fn kinds_share_their_ends() {
@@ -272,8 +411,8 @@ mod tests {
assert_eq!(ends(CornerKind::Chamfer), round);
// The inverted arc stays on the circle about the corner.
let c = path_corners(&star)[0];
let [s, e] = c.cut(3.0, CornerKind::InvertedRound);
let bez = kurbo::CubicBez::new(s.p, s.h_out, e.h_in, e.p);
let cut = c.cut(3.0, CornerKind::InvertedRound);
let bez = kurbo::CubicBez::new(cut.start, cut.h_out, cut.h_in, cut.end);
let r = c.setback(3.0);
for t in [0.25, 0.5, 0.75] {
use kurbo::ParamCurve;
+79 -15
View File
@@ -1,18 +1,60 @@
//! Hit testing primitives.
use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape};
use std::borrow::Cow;
use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
use crate::path::{FillRule, PathData};
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
let w = path.winding(p);
let w = closed(path).winding(p);
match rule {
FillRule::NonZero => w != 0,
FillRule::EvenOdd => w % 2 != 0,
}
}
/// `path` with each open subpath closed by a straight line back to its start, the edge its fill
/// has. kurbo's winding leaves that edge out, so a point beside it counted as outside the fill:
/// an open path lost its fill clicks on that side (a reflection moved the edge to the other one).
fn closed(path: &BezPath) -> Cow<'_, BezPath> {
let mut open = false;
let mut needs = false;
for el in path.elements() {
match el {
PathEl::MoveTo(_) => {
needs |= open;
open = true;
}
PathEl::ClosePath => open = false,
_ => {}
}
}
if !(needs || open) {
return Cow::Borrowed(path);
}
let mut out = BezPath::new();
let mut open = false;
for &el in path.elements() {
match el {
PathEl::MoveTo(_) => {
if open {
out.close_path();
}
open = true;
}
PathEl::ClosePath => open = false,
_ => {}
}
out.push(el);
}
if open {
out.close_path();
}
Cow::Owned(out)
}
/// Distance from `p` to the nearest point on the path outline.
pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 {
path.segments().map(|s| s.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
@@ -24,7 +66,10 @@ pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool {
quick.contains(p) && distance_to_outline(path, p) <= width / 2.0 + tol
}
/// Does the path intersect (or lie inside) the rect? Used for marquee selection.
/// Does the path touch the rect: an anchor inside it, or a segment crossing its edges? Used for
/// marquee selection, which (as in Illustrator) needs the path itself: a rect wholly inside a
/// filled shape doesn't touch it, so a marquee drawn over a background picks only what is on it
/// (#1103).
pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
let Some(b) = path.bounds() else { return false };
if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) {
@@ -41,16 +86,7 @@ pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
kurbo::Line::new((r.x1, r.y1), (r.x0, r.y1)),
kurbo::Line::new((r.x0, r.y1), (r.x0, r.y0)),
];
let bp = path.to_bezpath();
for seg in bp.segments() {
for e in &edges {
if !seg.intersect_line(*e).is_empty() {
return true;
}
}
}
// Rect entirely inside a closed shape counts as touching it.
path.is_closed() && fill_contains(&bp, FillRule::NonZero, r.center())
path.to_bezpath().segments().any(|seg| edges.iter().any(|e| !seg.intersect_line(*e).is_empty()))
}
#[cfg(test)]
@@ -77,6 +113,32 @@ mod tests {
assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0)));
}
/// An open path fills as if closed by a straight line back to its start, and is hit there,
/// whichever side that line falls on (a reflected open path lost its fill clicks).
#[test]
fn open_paths_fill_as_closed_on_either_side() {
// A filled sliver left open by a deleted anchor; its closing line runs down its left side.
let open = BezPath::from_svg(
"M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61",
)
.unwrap();
let mut closed = open.clone();
closed.close_path();
for p in [Point::new(100.0, 100.0), Point::new(95.0, 85.0), Point::new(92.0, 112.0)] {
assert!(closed.winding(p) != 0, "{p:?} is inside the closed sliver");
assert!(fill_contains(&open, FillRule::NonZero, p), "{p:?}: the open sliver's fill");
assert!(fill_contains(&open, FillRule::EvenOdd, p), "{p:?}: even-odd too");
}
assert!(!fill_contains(&open, FillRule::NonZero, Point::new(80.0, 100.0)), "left of the closing line");
// Mirrored, the closing line is on the other side: still filled.
let flipped = kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, 200.0, 0.0]) * open.clone();
assert!(fill_contains(&flipped, FillRule::NonZero, Point::new(100.0, 100.0)));
// Two open subpaths: each closes on its own.
let mut two = open.clone();
two.extend(kurbo::Affine::translate((0.0, 100.0)) * open.clone());
assert!(fill_contains(&two, FillRule::NonZero, Point::new(100.0, 100.0)) && fill_contains(&two, FillRule::NonZero, Point::new(100.0, 200.0)));
}
#[test]
fn marquee() {
let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0));
@@ -84,8 +146,10 @@ mod tests {
assert!(!intersects_rect(&p, Rect::new(20.0, 20.0, 30.0, 30.0)));
// corner region of the bbox that misses the ellipse outline
assert!(!intersects_rect(&p, Rect::new(0.0, 0.0, 0.5, 0.5)));
// fully inside
assert!(intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
// Wholly inside the shape, touching neither its anchors nor its outline (#1103).
assert!(!intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
// Around the whole shape.
assert!(intersects_rect(&p, Rect::new(-1.0, -1.0, 11.0, 11.0)));
// crossing a line
let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0)));
+18
View File
@@ -252,6 +252,24 @@ impl SubPath {
_ => a,
}
}
/// Bend segment `seg` so its point at `t` moves by `dv`: both inner control points move by
/// dv / 3t(1-t), since B(t) moves by 3t(1-t) times that. A smooth anchor stays smooth: its other
/// handle turns with the moved one. False when there is no such segment.
pub fn bend_segment(&mut self, seg: usize, t: f64, dv: kurbo::Vec2) -> bool {
let n = self.anchors.len();
if seg >= self.segment_count() || n == 0 {
return false;
}
let v = dv / (3.0 * t * (1.0 - t));
if let Some(a) = self.anchors.get_mut(seg % n) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = self.anchors.get_mut((seg + 1) % n) {
b.set_handle(false, b.h_in + v, false);
}
true
}
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
let n = self.anchors.len();
+7 -2
View File
@@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
let f = i as f64 / (concentric + 1) as f64;
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
}
for i in 0..radial.max(1) {
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
// No dividers at 0 (the loop never divides by it).
for i in 0..radial {
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
}
out
@@ -402,6 +403,10 @@ mod tests {
fn grids() {
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
}
}
#[test]
+23
View File
@@ -55,6 +55,18 @@ fn conventions_commands_batch_and_bounded_preview() {
assert_eq!(p["failed"], 1);
assert_eq!(p["completed"], total - 1);
}
// atomic: the whole batch is one undo step, named by `label` (#1163).
let undo_steps = |s: &mut Server| payload(&call(s, "doc_inspect", json!({"depth":0})))["history"].as_array().map_or(0, Vec::len);
let before = undo_steps(&mut s);
let two = json!([{"id":"shape.rectangle","params":{"x":0,"y":0,"width":5,"height":5}}, {"id":"shape.ellipse","params":{"x":9,"y":0,"width":5,"height":5}}]);
let r = call(&mut s, "command_batch", json!({"steps":two,"atomic":true,"label":"Agent build"}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(undo_steps(&mut s), before + 1);
assert_eq!(payload(&call(&mut s, "run_command", json!({"command":"edit.undo"})))["undone"], "Agent build");
assert_eq!(undo_steps(&mut s), before);
for bad in [json!({"steps":two,"label":"x"}), json!({"steps":two,"atomic":"yes"}), json!({"steps":two,"atomic":true,"label":""})] {
assert_eq!(call(&mut s, "command_batch", bad.clone())["isError"], true, "{bad}");
}
let before = call(&mut s, "doc_inspect", json!({}));
let r = call(&mut s, "render_preview", json!({"max_side":64}));
assert_eq!(r["isError"], false, "{r}");
@@ -65,6 +77,17 @@ fn conventions_commands_batch_and_bounded_preview() {
for max in [json!(0), json!(4097), json!("64")] {
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
}
// Another artboard, three times wider than tall, previews by its index (#1163).
let r = call(&mut s, "run_command", json!({"command":"artboard.new","params":{"x":2000,"y":0,"width":300,"height":100}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, "render_preview", json!({"max_side":60,"artboard":1}));
assert_eq!(r["isError"], false, "{r}");
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!((img.width(), img.height()), (60, 20));
for board in [json!(2), json!(-1), json!("1")] {
assert_eq!(call(&mut s, "render_preview", json!({"artboard":board}))["isError"], true, "{board}");
}
for name in ["ui_inspect", "ui_screenshot"] {
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
}
+34 -12
View File
@@ -96,9 +96,9 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_batch",
"Run several commands",
"Run steps in order; each edit is its own undo step. A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
"Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
obj(
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step (needs an open document)"}, "label":string("The undo step's name with atomic")}),
&["steps"],
),
false,
@@ -113,8 +113,11 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"render_preview",
"Render a preview",
"Render the first artboard as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(json!({"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[]),
"Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(
json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"}}),
&[],
),
true,
),
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
@@ -138,7 +141,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"run_command",
"Run command",
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"align\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
obj(
json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}),
&["command"],
@@ -200,7 +203,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"draw_path",
"Draw path",
"Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Returns the new object id.",
"Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke). For a filled path without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.",
obj(
json!({
"points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}},
@@ -217,7 +220,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"draw_shape",
"Draw shape",
"Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Returns the new object id.",
"Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke); lines start with no fill and a visible stroke. For a filled shape without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.",
obj(
json!({
"shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]},
@@ -329,7 +332,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
obj(
json!({
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
@@ -338,8 +341,8 @@ pub fn tool_definitions() -> Vec<Value> {
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF default: all)"},
"range": string("1-based artboards such as \"1-3, 5\" (PDF: one page each; other formats take one)"),
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
"outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"},
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg"},
"outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"},
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"},
}),
&[],
),
@@ -902,6 +905,16 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
None => true,
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
};
let atomic = match a.get("atomic") {
None => false,
Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?,
};
if atomic {
let label = a.get("label").cloned().unwrap_or(Value::Null);
b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?;
} else if a.get("label").is_some() {
return Err("`label` names the undo step of an atomic batch: give atomic:true too".into());
}
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
let mut values: Vec<Value> = vec![];
@@ -936,6 +949,10 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
}
}
}
// The steps that ran stay, as one undo step.
if atomic {
b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?;
}
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results}));
r.is_error = failed > 0;
Ok(r)
@@ -946,8 +963,13 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
None => 1024,
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
};
let index = match a.get("artboard") {
None => 0,
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
};
let doc = b.call("document.inspect", json!({"depth":0}))?;
let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.first()).ok_or("no such artboard")?;
let board =
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
@@ -959,7 +981,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
if width.max(height) * scale > 4096.0 {
return Err("artboard is too large for a bounded preview".into());
}
let r = b.call("ui.render", json!({"scale":scale}))?;
let r = b.call("ui.render", json!({"scale":scale, "artboard": index}))?;
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
+144 -4
View File
@@ -235,12 +235,16 @@ pub(crate) struct Tidy {
keep: Vec<Point>,
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
curves: Vec<(kurbo::Rect, CubicBez)>,
/// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose
/// bounds can hold a point, without testing them all.
by_x: Vec<usize>,
max_width: f64,
}
impl Tidy {
/// Merge any smooth joint.
pub(crate) fn free(precision: f64) -> Self {
Self { precision, keep: Vec::new(), curves: Vec::new() }
Self { precision, ..Default::default() }
}
/// Keep every on-curve point of `paths`.
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
@@ -257,7 +261,10 @@ impl Tidy {
}
}
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
Self { precision, keep, curves }
let mut by_x: Vec<usize> = (0..curves.len()).collect();
by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0));
let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max);
Self { precision, keep, curves, by_x, max_width }
}
fn is_kept(&self, p: Point) -> bool {
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
@@ -322,10 +329,11 @@ impl Arrangement {
pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator<Item = ContourIdx>, tidy: &Tidy) -> PathData {
let mut subpaths = Vec::new();
for i in idx {
if is_sliver(&c[i].path, tidy.precision) {
let path = tidy.repair(&c[i].path);
if is_sliver(&path, tidy.precision) {
continue;
}
if let Some(sp) = tidy_bezpath(&c[i].path, tidy) {
if let Some(sp) = tidy_bezpath(&path, tidy) {
subpaths.push(sp);
}
}
@@ -515,6 +523,84 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
None
}
impl Tidy {
/// The input curves whose bounds hold both `a` and `b`.
fn curves_around(&self, a: Point, b: Point) -> impl Iterator<Item = &CubicBez> {
let x = a.x.min(b.x);
let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x);
let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width);
self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c)
}
/// `bp` with each curve piece that strays from the one input curve through both its ends
/// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't
/// follow its curve; out and back between two points of one curve, such pieces are a lens
/// instead of nothing (issue #1028). A piece on a curve, or whose ends two different input
/// curves pass through (a real lens between them), is left as it is.
pub(crate) fn repair(&self, bp: &BezPath) -> BezPath {
use kurbo::{ParamCurve, ParamCurveNearest, PathEl};
if self.curves.is_empty() {
return bp.clone();
}
let mut out = BezPath::new();
let mut cur = Point::ZERO;
let mut start = Point::ZERO;
for el in bp.iter() {
let piece = match el {
PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()),
PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)),
_ => None,
};
match (el, piece) {
(_, Some(raw)) => {
let (a, b) = (raw.p0, raw.p3);
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
let tol = 1e-6 * scale;
let mut subs: Vec<CubicBez> = vec![];
let mut follows = false;
for c in self.curves_around(a, b) {
let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12));
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 {
continue;
}
if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) {
follows = true;
break;
}
let sub = if na.t < nb.t {
c.subsegment(na.t..nb.t)
} else {
let r = c.subsegment(nb.t..na.t);
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
};
subs.push(CubicBez::new(a, sub.p1, sub.p2, b));
}
let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0;
match subs.split_first() {
Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b),
_ => out.push(el),
}
cur = b;
}
(PathEl::MoveTo(p), None) => {
out.push(el);
(cur, start) = (p, p);
}
(PathEl::LineTo(p), None) => {
out.push(el);
cur = p;
}
(PathEl::ClosePath, None) => {
out.push(el);
cur = start;
}
(_, None) => out.push(el),
}
}
out
}
}
/// Resolve self-intersections / overlaps of a single filled path into simple, consistently
/// oriented contours (outer contours and holes).
pub fn normalize(path: &PathData, rule: FillRule) -> PathData {
@@ -592,3 +678,57 @@ pub fn area(path: &PathData, rule: FillRule) -> f64 {
Err(_) => 0.0,
}
}
#[cfg(test)]
mod tests {
use kurbo::ParamCurve;
use super::*;
/// A quarter-circle-like arc, the input curve the pieces below are cut from.
fn arc() -> CubicBez {
CubicBez::new((0.0, 0.0), (0.0, 55.0), (45.0, 100.0), (100.0, 100.0))
}
#[test]
fn a_stray_piece_out_and_back_along_one_curve_is_no_outline() {
// Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and
// back by a piece that doesn't follow it, which refit into a visible lens.
let c = arc();
let tidy =
Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]);
let out = c.subsegment(0.2..0.6);
let mut contour = BezPath::new();
contour.move_to(out.p0);
contour.curve_to(out.p1, out.p2, out.p3);
// back to the start, bulging off the curve and overshooting the end like the reported piece
contour.curve_to(out.p0 + (out.p0 - out.p3) * 0.15 + kurbo::Vec2::new(4.0, -3.0), out.p1 + kurbo::Vec2::new(3.0, -2.0), out.p0);
contour.close_path();
assert!(!is_sliver(&contour, tidy.precision), "the stray piece makes a lens");
let fixed = tidy.repair(&contour);
assert!(is_sliver(&fixed, tidy.precision), "{}", fixed.to_svg());
// a piece on its curve is left exactly as it is
let mut on = BezPath::new();
on.move_to(out.p0);
on.curve_to(out.p1, out.p2, out.p3);
on.line_to(out.p0);
on.close_path();
assert_eq!(tidy.repair(&on), on);
}
#[test]
fn a_lens_between_two_curves_through_the_same_points_stays() {
// Two input curves meeting at both ends bound a real face: neither piece is "repaired"
// onto the other.
let lower = CubicBez::new((0.0, 0.0), (30.0, 20.0), (70.0, 20.0), (100.0, 0.0));
let upper = CubicBez::new((0.0, 0.0), (30.0, -20.0), (70.0, -20.0), (100.0, 0.0));
let input = |c: CubicBez| BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)]);
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&input(lower), &input(upper)]);
let mut lens = BezPath::new();
lens.move_to(lower.p0);
lens.curve_to(lower.p1, lower.p2, lower.p3);
lens.curve_to(upper.p2, upper.p1, upper.p0);
lens.close_path();
assert_eq!(tidy.repair(&lens), lens);
}
}
+25
View File
@@ -192,4 +192,29 @@ mod tests {
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
}
#[test]
fn miter_offset_finishes_where_the_sweep_used_to_loop() {
// Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and
// its memory grew without bound, freezing the app. A synthetic outline (found by search)
// with the same failure as the reported glyph; the exact coordinates matter.
const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \
L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \
L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \
Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \
Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \
Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z";
let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let input = path.clone();
std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0)));
let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else {
// the stuck sweep keeps allocating on its thread: end the test binary now
eprintln!("Offset Path didn't finish in 30 s (issue #1028)");
std::process::exit(101);
};
let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero));
assert_eq!(out.subpaths.len(), 1);
assert!(after > before, "{before} → {after}");
}
}
+23 -4
View File
@@ -268,23 +268,42 @@ pub fn merge_regions(regions: &[&Region]) -> PathData {
/// path, from the arrangement itself, made once for every merge of the set: the faces' contours
/// share their edges exactly, so the edges between merged faces vanish. Uniting the faces' tidied
/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut)
/// fall back to [`merge_regions`].
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as
/// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way
/// (#893). Either falls back to [`merge_regions`] when it can't place a face.
pub struct FaceMerger {
/// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes.
arr: Option<Arrangement>,
shapes: usize,
/// With open paths, the shapes, and their planar map once a merge has needed it.
lines: Option<(Vec<Shape>, std::cell::OnceCell<Option<crate::planar::Map>>)>,
}
impl FaceMerger {
pub fn new(shapes: &[Shape]) -> Self {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len() }
// The Shape Builder's faces come from the planar map only up to its cap.
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines }
}
/// The union of `faces`.
pub fn merge(&self, faces: &[&Region]) -> PathData {
self.exact(faces).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
}
/// `faces` merged in the planar map of shapes with open paths: each face found again by a
/// point inside it, and the result checked to cover what they cover.
fn planar(&self, faces: &[&Region]) -> Option<PathData> {
let (shapes, map) = self.lines.as_ref()?;
let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?;
let inside: Vec<Point> = faces.iter().map(|r| crate::planar::interior_point(&r.path)).collect::<Option<_>>()?;
let merged = map.merge(&inside)?;
// A face not found as itself (two points in one face of the map) shows in the area.
let want: f64 = faces.iter().map(|r| crate::area(&r.path, FillRule::NonZero)).sum();
let outlines: f64 = faces.iter().map(|r| r.path.to_bezpath().perimeter(1e-6)).sum();
((crate::area(&merged, FillRule::NonZero) - want).abs() <= 1e-9 * want.max(1.0) + crate::DEFAULT_PRECISION * outlines).then_some(merged)
}
fn exact(&self, faces: &[&Region]) -> Option<PathData> {
+206 -17
View File
@@ -149,6 +149,20 @@ struct Edge {
source: usize,
}
/// `segs` (a chain) moved to start at `a` and end at `b`, the handles next to each moving with it.
fn pin_ends(segs: &mut [Seg], a: Point, b: Point) {
if let Some(s) = segs.first_mut() {
let d = a - s.c.p0;
s.c.p0 = a;
s.c.p1 += d;
}
if let Some(s) = segs.last_mut() {
let d = b - s.c.p3;
s.c.p3 = b;
s.c.p2 += d;
}
}
/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it.
fn twin(h: usize) -> usize {
h ^ 1
@@ -465,7 +479,24 @@ fn span(sp: &SubPath, u0: f64, u1: f64) -> Option<SubPath> {
segs_to_subpath(&segs, false)
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
/// The planar graph of `shapes` (in the sweep's frame) with its faces walked: what [`build`] makes
/// Live Paint faces and edges from, and what [`Map::merge`] merges faces in.
pub(crate) struct Map {
edges: Vec<Edge>,
around: Vec<Vec<usize>>,
slot_of: Vec<usize>,
/// Bounded faces: their cycle of half-edges, path and area.
faces: Vec<(Vec<usize>, BezPath, f64)>,
/// Outer boundaries of connected pieces, and the face each one is a hole in.
holes: Vec<(Vec<usize>, BezPath)>,
face_holes: Vec<Vec<usize>>,
tidy: Tidy,
/// The inputs' anchors (sorted by x) and how close results snap back onto them.
anchors: Vec<Point>,
snap: f64,
}
pub(crate) fn map(shapes: &[Shape]) -> Option<Map> {
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
for (i, s) in shapes.iter().enumerate() {
let mut bp = BezPath::new();
@@ -508,11 +539,11 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
.filter_map(|k| grid.get(&k))
.flatten()
.copied()
.find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps));
.find_map(|v| points.get(v).filter(|q| q.distance(p) <= eps).map(|&q| (v, q)));
near.unwrap_or_else(|| {
points.push(p);
grid.entry((cx, cy)).or_default().push(points.len() - 1);
points.len() - 1
(points.len() - 1, p)
})
};
let mut edges: Vec<Edge> = Vec::new();
@@ -520,11 +551,16 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let outlines = Outlines::new(&inputs, eps);
for idx in top.segment_indices() {
let half = idx.first_half();
let segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())];
if segs.is_empty() || ends[0] == ends[1] {
let mut segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
let ((v0, p0), (v1, p1)) = (vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo()));
let ends = [v0, v1];
if segs.is_empty() || v0 == v1 {
continue;
}
// Every edge at a vertex starts or ends exactly on it, so faces meeting there share its
// coordinates: merging them (a union) then cancels their common edge, which it can't when
// each face carries its own rounding of the corner (#893).
pin_ends(&mut segs, p0, p1);
let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue };
// Coincident pieces the sweep left apart are one edge (the front-most input's).
let key = (ends[0].min(ends[1]), ends[0].max(ends[1]));
@@ -637,22 +673,89 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
}
}
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
// The inputs' anchors, which results land on again up to the sweep's tolerance.
let snap = 2.0 * eps;
let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
Some(Map { edges, around, slot_of, faces, holes, face_holes, tidy, anchors, snap })
}
impl Map {
/// The union of the faces that hold `inside` (document points, one per face), traced along the
/// map's own edges: those between two merged faces drop out exactly, where a boolean union of
/// the faces' outlines can leave them in when its sweep rounds their shared corners apart
/// (#893). Outer outlines come out positive, holes negative. None when a point is in no face.
pub(crate) fn merge(&self, inside: &[Point]) -> Option<PathData> {
let halves = 2 * self.edges.len();
// The face on each half-edge's side; holes bound the face around them.
let mut face_of: Vec<Option<usize>> = vec![None; halves];
for (i, (cycle, ..)) in self.faces.iter().enumerate() {
let hole_cycles = self.face_holes.get(i)?.iter().filter_map(|&k| self.holes.get(k)).map(|(c, _)| c);
for &h in std::iter::once(cycle).chain(hole_cycles).flatten() {
*face_of.get_mut(h)? = Some(i);
}
}
let mut picked = vec![false; self.faces.len()];
for &p in inside {
let q = SHEAR * p;
// The smallest face around it: faces of a piece inside a hole lie within the face around.
let (i, _) = self
.faces
.iter()
.enumerate()
.filter(|(_, (_, bp, _))| bp.bounding_box().contains(q) && bp.winding(q) != 0)
.min_by(|a, b| a.1.2.total_cmp(&b.1.2))?;
*picked.get_mut(i)? = true;
}
let merged = |h: usize| face_of.get(h).copied().flatten().is_some_and(|f| picked.get(f) == Some(&true));
let boundary = |h: usize| merged(h) && !merged(twin(h));
let mut used = vec![false; halves];
let mut subpaths = Vec::new();
for start in 0..halves {
if !boundary(start) || used.get(start).copied().unwrap_or(true) {
continue;
}
let mut cycle = vec![];
let mut h = start;
while !used.get(h).copied().unwrap_or(true) {
if let Some(u) = used.get_mut(h) {
*u = true;
}
cycle.push(h);
// Turn from the way back as a face walk does, past the edges inside the merged
// faces (between two of them, or a line's loose end), to the next boundary edge.
let back = twin(h);
let list = self.around.get(origin(&self.edges, back)?)?;
let k = *self.slot_of.get(back)?;
let n = list.len();
h = (1..=n).filter_map(|j| list.get((k + n * 2 - j) % n).copied()).find(|&g| boundary(g))?;
}
if h != start {
return None;
}
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&self.edges, h)).collect();
subpaths.push(segs_to_subpath(&tidy_segments(segs, true, &self.tidy), true)?);
}
(!subpaths.is_empty()).then(|| unshear(PathData::new(subpaths), &self.anchors, self.snap))
}
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let m = map(shapes)?;
let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m;
let snap = *snap;
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
let subpath = |cycle: &[usize]| {
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect();
segs_to_subpath(&tidy_segments(segs, true, &tidy), true)
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(edges, h)).collect();
segs_to_subpath(&tidy_segments(segs, true, tidy), true)
};
let regions = faces
.iter()
.zip(&face_holes)
.zip(face_holes)
.filter_map(|((cycle, _, _), hs)| {
let mut subpaths = vec![subpath(cycle)?];
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
let path = unshear(PathData::new(subpaths), &anchors, snap);
let path = unshear(PathData::new(subpaths), anchors, snap);
let sources =
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
Some(Region { path, sources })
@@ -680,13 +783,13 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
return Some((segs, true, source));
}
*u = true;
segs.extend(half_segs(&edges, h));
let v = origin(&edges, twin(h))?;
let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) };
segs.extend(half_segs(edges, h));
let v = origin(edges, twin(h))?;
let Some((a, b)) = passes(v) else { return Some((segs, origin(edges, h0) == Some(v), source)) };
h = if a == twin(h) { b } else { a };
}
};
for v in 0..nv {
for v in 0..around.len() {
if passes(v).is_some() {
continue;
}
@@ -709,8 +812,8 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let edge_shapes = chains
.into_iter()
.filter_map(|(segs, closed, source)| {
let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?;
Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
let sp = segs_to_subpath(&tidy_segments(segs, closed, tidy), closed)?;
Some(Shape::new(unshear(PathData::new(vec![sp]), anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
})
.collect();
Some((regions, edge_shapes))
@@ -913,6 +1016,92 @@ mod tests {
assert_eq!(edges.len(), 1);
}
/// A strip merged across an axonometric grid of lines (#893, the reporter's file): faces meeting
/// at a vertex share its exact coordinates, so the merge is one outline with no edge left inside.
#[test]
fn faces_share_their_corners_exactly_so_merging_them_leaves_no_seam() {
const LINES: &[[f64; 4]] = &[
[416.57797027866155, 268.45362144397683, 416.5779702786616, 811.5463785560231],
[243.12292300414873, 511.1134238473054, 713.4550472745127, 782.6598024033286],
[836.8770769958512, 511.1134238473054, 366.5449527254871, 782.6598024033286],
[457.718646852441, 268.45362144397683, 457.718646852441, 811.5463785560231],
[263.6932612910385, 475.4845528055331, 734.0253855614023, 747.0309313615563],
[816.3067387089613, 475.4845528055331, 345.9746144385975, 747.0309313615563],
[498.85932342622044, 268.45362144397683, 498.85932342622044, 811.5463785560231],
[284.26359957792823, 439.8556817637607, 754.5957238482922, 711.4020603197839],
[795.7364004220717, 439.8556817637607, 325.40427615170773, 711.4020603197839],
[539.9999999999998, 268.45362144397683, 539.9999999999998, 811.5463785560231],
[304.83393786481787, 404.22681072198844, 775.1660621351818, 675.7731892780117],
[775.1660621351821, 404.22681072198844, 304.833937864818, 675.7731892780117],
[581.1406765737793, 268.45362144397683, 581.1406765737793, 811.5463785560231],
[325.4042761517076, 368.59793968021614, 795.7364004220715, 640.1443182362394],
[754.5957238482922, 368.59793968021614, 284.26359957792835, 640.1443182362394],
[622.2813531475588, 268.45362144397683, 622.2813531475588, 811.5463785560231],
[345.97461443859737, 332.9690686384438, 816.3067387089613, 604.515447194467],
[734.0253855614026, 332.9690686384438, 263.6932612910385, 604.515447194467],
[663.422029721338, 268.45362144397683, 663.422029721338, 811.5463785560231],
[366.54495272548706, 297.3401975966715, 836.877076995851, 568.8865761526947],
[713.4550472745128, 297.3401975966715, 243.12292300414884, 568.8865761526947],
];
let s: Vec<Shape> = LINES.iter().enumerate().map(|(i, &[x0, y0, x1, y1])| line((x0, y0), (x1, y1), i as u64)).collect();
let (faces, _) = live_paint(&s);
let corners: Vec<Point> = faces.iter().flat_map(|f| f.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
for p in &corners {
for q in &corners {
assert!(p == q || p.distance(*q) > 1e-6, "one corner, two positions: {p:?} {q:?}");
}
}
// Down the strip between the lines x ≈ 498.9 and x = 540: 11 triangles, one outline.
let mut strip: Vec<&Region> = vec![];
for k in 0..=240 {
if let Some(f) = faces.iter().find(|f| f.contains(Point::new(519.43, 420.0 + f64::from(k))))
&& !strip.iter().any(|g| std::ptr::eq(*g, f))
{
strip.push(f);
}
}
assert_eq!(strip.len(), 11);
// As the Shape Builder merges them: in the map, so no sweep can round the corners apart.
let merged = crate::FaceMerger::new(&s).merge(&strip);
assert_eq!(merged.subpaths.len(), 1, "{merged:?}");
let want: f64 = strip.iter().map(|f| area(f)).sum();
assert!((crate::area(&merged, FillRule::NonZero) - want).abs() < 1e-6 * want);
}
/// Merging faces of a grid of lines in the map: a ring keeps its hole, cells that only touch at
/// a corner stay two outlines, a line's loose end inside the merge leaves no spur.
#[test]
fn faces_merge_in_the_map_with_holes_corners_and_loose_ends() {
let mut s = vec![];
for k in 0..4 {
let v = f64::from(k) * 10.0;
s.push(line((v, -5.0), (v, 35.0), 0));
s.push(line((-5.0, v), (35.0, v), 1));
}
// A loose end poking into the middle cell.
s.push(line((15.0, 10.0), (15.0, 15.0), 2));
let (faces, _) = live_paint(&s);
let cell = |x: f64, y: f64| at(&faces, x, y)[0];
let merger = crate::FaceMerger::new(&s);
let m = map(&s).unwrap();
let check = |picked: &[&Region], subpaths: usize, area_want: f64| {
// Merged in the map itself, not by the fallback union.
let inside: Vec<Point> = picked.iter().map(|f| interior_point(&f.path).unwrap()).collect();
assert_eq!(m.merge(&inside).as_ref(), Some(&merger.merge(picked)));
let m = merger.merge(picked);
assert_eq!(m.subpaths.len(), subpaths, "{m:?}");
assert!((crate::area(&m, FillRule::NonZero) - area_want).abs() < 1e-6, "{}", crate::area(&m, FillRule::NonZero));
m
};
let all: Vec<&Region> = faces.iter().collect();
let whole = check(&all, 1, 900.0);
assert_eq!(whole.subpaths[0].anchors.len(), 4, "one square, no anchors left from the inner lines or the loose end");
let ring: Vec<&Region> =
[(5., 5.), (15., 5.), (25., 5.), (5., 15.), (25., 15.), (5., 25.), (15., 25.), (25., 25.)].iter().map(|&(x, y)| cell(x, y)).collect();
check(&ring, 2, 800.0);
check(&[cell(5.0, 5.0), cell(15.0, 15.0)], 2, 200.0);
}
#[test]
fn shape_builder_lines_cut_regions() {
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
+11 -5
View File
@@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
@@ -692,8 +692,9 @@ struct Builder<'p> {
/// Mask keys of CMYK images whose mask turned out to hide nothing.
opaque: HashSet<u128>,
warnings: Vec<String>,
/// Fonts by cache key → base font name (from [`scan_page`]).
fonts: HashMap<u128, String>,
/// Fonts by cache key → base font name and whether the file carries its glyphs (from
/// [`scan_page`]).
fonts: FontNames,
font_names: HashMap<u128, FontInfo>,
families: Option<Families>,
missing_fonts: Vec<String>,
@@ -1346,7 +1347,7 @@ impl<'p> Builder<'p> {
return n.clone();
}
let data = o.font_data();
let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic));
// A subset's six-letter tag.
let ps = name.as_deref().map(|n| match n.split_once('+') {
@@ -1429,7 +1430,12 @@ impl<'p> Builder<'p> {
let upright = width.is_some_and(|w| w.abs() <= 1.0);
let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0);
let mut info = self.font_name(key, o);
if let Some(face) = &info.face {
// A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which
// style of the installed family it is, so the name decides.
let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e);
if let Some(face) = &info.face
&& embedded
{
let decided = match self.matched.get(&key) {
Some(m) => m.clone(),
// A glyph that can't be compared (several characters, a vertical form) decides
+31 -6
View File
@@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
Object::Array(a) => {
let mut it = a.flex_iter();
match it.next::<Name<'_>>()?.as_str() {
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(b"N")? {
1 => Some(Alt::Gray),
3 => Some(Alt::Rgb),
4 => Some(Alt::Cmyk),
_ => None,
},
"ICCBased" => {
let profile = it.next::<Stream<'_>>()?;
match profile.dict().get::<u8>(b"N")? {
1 => Some(Alt::Gray),
3 if lab_profile(&profile) => Some(Alt::Lab),
3 => Some(Alt::Rgb),
4 => Some(Alt::Cmyk),
_ => None,
}
}
"Lab" => Some(Alt::Lab),
"CalRGB" => Some(Alt::Rgb),
"CalGray" => Some(Alt::Gray),
@@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
}
}
/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's
/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their
/// alternate space (#1032), and their tint transforms give Lab values.
fn lab_profile(profile: &Stream<'_>) -> bool {
use std::io::Read;
if let Some(Object::Array(a)) = profile.dict().get::<Object<'_>>(b"Alternate") {
return a.flex_iter().next::<Name<'_>>().is_some_and(|n| n.as_str() == "Lab");
}
// The header's first 20 bytes, without decoding the rest of the profile.
let raw = profile.raw_data();
let mut head = vec![];
match profile.filters().as_slice() {
[] => head.extend(raw.iter().take(20)),
[hayro_syntax::Filter::FlateDecode] => {
let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab
}
_ => return false,
}
head.get(16..20) == Some(b"Lab ")
}
/// An alternate-space colour (the tint transform's output) as a VectorCraft colour.
fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> {
let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite());
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more