Compare commits

...
Author SHA1 Message Date
andeecollardandClaude Opus 5.5 b7018a3370 docs: quick Image Trace presets trace at once; the Image Trace button opens the controls
The UI parity row (and G12 in gaps.md, M12 in roadmap.md) said quick Image
Trace presets reveal the editable controls. Since baadbe2c6 a quick trace
leaves the panel as it was (test a_quick_trace_leaves_the_panel_as_it_was)
and the Image Trace button in the Control bar or Properties opens the
controls. That matches the public documentation: choosing a preset traces
the image with it straight away, and the panel is opened with the Image
Trace button in the Control panel or the Properties panel's Image Trace
section
(https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:22 +07:00
bflatastic 39979d4753 Tests: the floating-panel test skips fills that paint nothing (#2421) 2026-10-17 13:20:22 +07:00
bflatastic 47b6b3285d Panels: floating groups get an opaque fill beneath them; the docked card keeps its rounded corners (#2421) 2026-10-17 13:06:52 +07:00
vangel76andClaude Opus 5.5 ecbbba4f2e Panels: floating panel groups paint an opaque background (#2421)
The shared dock left its pane and tab strip backgrounds transparent because the docked card behind them is filled. Floating groups lie on the canvas, so the canvas showed through. They now paint the panel colour, which is what the card shows when docked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:52 +07:00
bflatastic 424a49604c Layers: Merge Selected keeps to the highlighted layers, as documented (#2383)
Reverts the fallback that merged the layers of art selected on the canvas when no
layer row is highlighted: Illustrator's Merge Selected merges the highlighted rows
into the last one highlighted.
2026-10-17 13:06:52 +07:00
vangel76andClaude Opus 5.5 fdc08e25d6 Layers: Merge Selected merges the layers of art selected on the canvas (#2383)
With no highlighted rows and art selected in two or more layers (Select All), layer.merge merges those layers; the panel menu enables it then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 1ff7ac7557 Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
bflatastic 2a3efa02f2 Overprint Preview: overprinting characters draw with Darken too (#2415) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 7e423f91e9 Overprint Preview: overprinting draws with Darken, so a colour over itself stays that colour (#2415)
Multiply squared a colour overprinting itself (stroke over same-colour fill showed a darker band). Darken keeps the stronger ink per plate/channel.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 5fd7fd4312 Character: the Kerning field at a caret kerns the pair, not the whole text (#2217)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 57c79dc0b4 PNG export: an sRGB chunk marks the colours as sRGB, embedIcc turns it off (#2221)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
bflatastic 1fbd35560d Tests: the cursor-theme test compares paths, so it passes on Windows (#2450) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 37dfdd5da5 Cursor: keep the system pointer where no cursor theme loads on Wayland (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
vangel76andClaude Opus 5.5 743930ac11 Wayland: give winit the desktop cursor theme so the pointer returns over panels (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
dotCooCoo 476d228262 Tests: a language one ui-egui test sets no longer translates the tests running beside it
The language the UI is drawn in was one process-wide value in unit
tests too, and the tests run on parallel threads.
current_language_is_a_frame_setting sets Traditional Chinese for a
moment, and a test that drew text in that moment read it in Chinese.
chrome::tests::the_zoom_hint_follows_animated_zoom failed that way in
29 of 500 runs together with the i18n tests.

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

The first tmp() call in each test process now removes the folder and
creates it again empty. The roots test's body moves into
automation_roots(), and mcp_automation_roots_in_a_folder_an_earlier_run_left
runs it twice in one folder, emptying the folder in between as tmp()
does. Without the removal the second run fails with the same error.
2026-10-17 12:50:38 +07:00
dotCooCoo efdb04db8a Lint: action_batch.rs compiles without an unused import on wasm32
On main, cargo xtask wasm printed an unused-import warning for json in
crates/engine/src/cmd/fileio/action_batch.rs, and this check reported
it as an error:

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

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

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

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

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

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

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

Docs: docs/mcp.md (addAround).
2026-10-17 12:50:36 +07:00
bflatastic 0af1741e24 Crop Image: the RGBA size check can't overflow on a huge header 2026-10-17 12:50:36 +07:00
dotCooCoo 6b0d1ed1c0 Crop Image: trim checks the image's RGBA size before decoding it
Crop Image's trim (object.cropImage {trim: true}) decoded the image
within MAX_RASTER_ALLOC (2 GB) and then converted it to RGBA. The
decoder holds the decoded image, before conversion, to that allowance,
so a gray or 1-bit image within it could take up to four times as much
once converted.

Trim now reads the image's size from its header first and, when
width × height × 4 is over the allowance, fails before anything is
decoded, with the check and message Load Texture uses: "an image of
W × H pixels is too large to draw". Images within the allowance are
trimmed as before.
2026-10-17 12:48:19 +07:00
dotCooCoo db2bbcf405 Images: sampling and recoloring check an image's RGBA size without overflowing
Before the Eyedropper samples an image, or Edit Colors, Recolor Artwork
or a color adjustment effect recolors an embedded one, `rgba_within` reads
the image's size from its header and checks its size as RGBA against the
allowance. The check multiplied width × height × 4 in 64 bits. A TIFF's
sides go up to 2^32 - 1 pixels, so a header of 2^31 × 2^31 pixels
overflowed that product: debug and test builds panicked with "attempt to
multiply with overflow", and release builds wrapped it to 0 before the
decoder's own 512 MB limit refused the image.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Every complete catalog gets a row for the label.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #2241

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

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

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

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

Part of #2020.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+1 -44
View File
@@ -36,47 +36,4 @@ jobs:
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
package:
name: FreeBSD 14 (package)
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
# and on demand, not on every push to main.
if: github.event_name != 'push'
runs-on: ubuntu-latest
timeout-minutes: 150
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
mem: 12288
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
# The craft-fonts checkout is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
# Every part the package must have, the craft-fonts licences included.
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: freebsd-package
path: dist/release/*
if-no-files-found: error
run-package-check: true
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
Generated
+28 -28
View File
@@ -4507,7 +4507,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4542,7 +4542,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4553,7 +4553,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4565,7 +4565,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4581,7 +4581,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4603,7 +4603,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4615,7 +4615,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4632,7 +4632,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4648,7 +4648,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4684,7 +4684,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4705,7 +4705,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"proptest",
@@ -4720,7 +4720,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4732,7 +4732,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"log",
@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4765,7 +4765,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4777,7 +4777,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"aes",
"craft-pdf",
@@ -4807,7 +4807,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -4821,7 +4821,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"gif",
@@ -4848,14 +4848,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4879,7 +4879,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4896,7 +4896,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4916,7 +4916,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4924,7 +4924,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4940,7 +4940,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4952,7 +4952,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-ui",
"egui",
@@ -4986,7 +4986,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -6096,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -1
View File
@@ -245,7 +245,7 @@ The scores are self-assessed from our code against Illustrator's public document
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
+4 -2
View File
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@@ -80,7 +80,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| 3D and Materials | ~25% | 30–55 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
| Symbols, blends, envelopes, Repeat, perspective | 87% | 5–9 |
| Image Trace, graphs, image tools | 75% | 5–8 |
| Layers, artboards, document setup | 78% | 5–8 |
| View & navigation | 70% | 9–14 |
@@ -134,6 +134,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
+24 -3
View File
@@ -7,10 +7,18 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
/// that folder and creates it again empty.
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name)
}
/// Removes `dir` and everything in it, creates it again empty and returns it.
fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
dir
}
#[test]
@@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() {
/// server starts, and the process then reads and writes only inside its roots.
#[test]
fn mcp_automation_roots() {
let base = tmp("roots");
automation_roots(&tmp("roots"));
}
/// A run that has the process id of an earlier run finds that run's files and links in its folder.
/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing
/// with "File exists".
#[test]
fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
let dir = tmp("earlier-run");
automation_roots(&dir.join("roots"));
automation_roots(&empty_dir(dir).join("roots"));
}
fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
+9 -2
View File
@@ -8,9 +8,16 @@ use serde_json::Value;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
/// removes that folder and creates it again empty.
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
let dir = DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
});
dir.join(name).to_string_lossy().to_string()
}
+212
View File
@@ -0,0 +1,212 @@
//! Linux Wayland: tell winit where the cursor theme is (#2450).
//!
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
//!
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
//!
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
//! any thread starts and before the window opens.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
#[derive(Debug, Default, PartialEq)]
pub struct DesktopCursor {
pub theme: Option<String>,
pub size: Option<u32>,
}
/// The variables to set, given the environment, the host icon folders that exist and the
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
/// stay as they are.
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
return Vec::new();
}
let mut out = Vec::new();
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
}
if !set("XCURSOR_THEME")
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
{
out.push(("XCURSOR_THEME", OsString::from(theme)));
}
if !set("XCURSOR_SIZE")
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
{
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
}
out
}
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
/// The themes `index.theme` lists after `Inherits=`.
fn inherits(index: &str) -> Vec<String> {
index
.lines()
.find_map(|l| l.trim().strip_prefix("Inherits="))
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
.unwrap_or_default()
}
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
/// whether a file is there.
pub fn theme_usable(
var: impl Fn(&str) -> Option<OsString>,
home: Option<&Path>,
read: impl Fn(&Path) -> Option<String>,
exists: impl Fn(&Path) -> bool,
) -> bool {
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
Some(v) => std::env::split_paths(&v).collect(),
None => DEFAULT_ICON_DIRS
.iter()
.filter_map(|d| match d.strip_prefix("~/") {
Some(rest) => home.map(|h| h.join(rest)),
None => Some(PathBuf::from(d)),
})
.collect(),
};
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
let mut todo = vec![first];
let mut seen: Vec<String> = Vec::new();
while let Some(theme) = todo.pop() {
// Bounded: themes inherit in short chains, and a cycle must end.
if seen.contains(&theme) || seen.len() >= 16 {
continue;
}
for dir in &dirs {
let base = dir.join(&theme);
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
return true;
}
if let Some(index) = read(&base.join("index.theme")) {
todo.extend(inherits(&index));
}
}
seen.push(theme);
}
false
}
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
/// sets a pointer, and the canvas must not hide the system one (#2450).
pub fn theme_missing() -> bool {
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
}
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
fn gsettings_value(output: &str) -> &str {
output.trim().trim_matches('\'')
}
#[cfg(target_os = "linux")]
fn desktop_cursor() -> DesktopCursor {
let get = |key: &str| {
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
};
DesktopCursor {
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
}
}
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
/// unset. Call first thing in `main`, while the process is single-threaded.
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub fn apply() {
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
log::debug!("cursor theme: {name}={value:?}");
// SAFETY: called at the top of `main`, before the app starts any thread.
unsafe { std::env::set_var(name, value) };
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
}
fn adwaita() -> DesktopCursor {
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
}
#[test]
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
assert_eq!(
got,
vec![
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
("XCURSOR_THEME", OsString::from("Adwaita")),
("XCURSOR_SIZE", OsString::from("24")),
]
);
}
#[test]
fn settings_the_user_made_and_other_sessions_stay_untouched() {
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
// A theme-less desktop and an absurd size set nothing.
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
}
#[test]
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
// Compared as paths, so the joins match on Windows too, where they use `\`.
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
let read = |p: &Path| {
if p == Path::new("/i/Breeze/index.theme") {
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
} else if p == Path::new("/i/Loop/index.theme") {
Some("Inherits=Loop\n".to_owned())
} else {
None
}
};
let with = |theme: &'static str| {
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
};
assert!(theme_usable(with("Bibata"), None, read, exists));
assert!(theme_usable(with("Breeze"), None, read, exists));
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
assert!(!theme_usable(with("Nope"), None, read, exists));
assert!(!theme_usable(with("Loop"), None, read, exists));
// Unset: Xcursor's default folders and the `default` theme.
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
}
#[test]
fn gsettings_output_is_unquoted() {
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
assert_eq!(gsettings_value("24\n"), "24");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
//! The panel gets a Format menu as its accessory view
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
//! `beginSheetModalForWindow:completionHandler:`
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
//! one. The save command takes the format from the extension, so an extension typed by hand still
//! decides it.
//!
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
//! the menu's action have no safe API; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
const MARGIN: f64 = 12.0;
const GAP: f64 = 8.0;
/// The Format menu's size, in points.
const MENU: NSSize = NSSize::new(260.0, 26.0);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
set_allowed_types(&panel, &self.ivars().filters, index);
let name = panel.nameFieldStringValue().to_string();
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
}
}
);
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if named.len() < 2 { Vec::new() } else { named }
}
/// The menu entry of the suggested name's type, or the first (the request's default).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = fileio::extension(name);
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
}
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
/// writes) is replaced, and other dots stay part of the name (a version number).
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return String::new();
}
let has = fileio::extension(name);
let known =
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
}
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
first.iter().chain(others).copied().collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
#[allow(deprecated)]
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
/// named file types, or `window` has no AppKit window; nothing is shown then.
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
let mtm = MainThreadMarker::new()?;
let filters = menu_formats(&pick.filters);
if filters.is_empty() {
return None;
}
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and winit hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let parent = view.window()?;
let panel = NSSavePanel::savePanel(mtm);
let selected = initial_format(&pick.name, &filters);
set_allowed_types(&panel, &filters, selected);
if let Some(folder) = &pick.folder {
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
}
panel.setExtensionHidden(false);
if !pick.name.is_empty() {
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
}
let accessory = accessory(mtm, &panel, filters, selected);
panel.setAccessoryView(Some(&accessory));
let (tx, rx) = channel();
let answered = panel.clone();
let handler = RcBlock::new(move |response: NSModalResponse| {
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
// The app gone meanwhile has no use for the answer.
let _ = tx.send(paths);
});
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
// until the sheet closes.
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
Some(rx)
}
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.sizeToFit();
let size = label.frame().size;
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
let x = MARGIN + size.width + GAP;
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
for (name, _) in &filters {
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
// sheet is open. The selector has the target/action signature NSControl calls it with.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
}
accessory.addSubview(&label);
accessory.addSubview(&popup);
accessory
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
let save = menu_formats(&fileio::save_filters("vectorcraft"));
assert!(save.len() >= 2, "{save:?}");
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
assert_eq!(initial_format(name, FILTERS), index, "{name}");
}
}
#[test]
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
for (name, expected) in [
("図面 1.vectorcraft", "図面 1.svg"),
("a.PDF", "a.svg"),
("art", "art.svg"),
("art.v2", "art.v2.svg"),
("art.svg", "art.svg"),
("old.drawcraft", "old.svg"),
("photo.png", "photo.svg"),
("", ""),
] {
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
}
}
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
for (label, extensions) in &filters {
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
}
}
/// The menu's label is translated in every language with complete menus.
#[test]
fn the_label_is_translated() {
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
}
}
}
+20 -7
View File
@@ -27,6 +27,7 @@ mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
mod logging;
@@ -35,6 +36,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
@@ -473,7 +476,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
// The panel is built and the sheet is begun below, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
@@ -485,12 +488,18 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
@@ -517,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -646,6 +656,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
";
fn main() -> std::process::ExitCode {
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
#[cfg(target_os = "linux")]
cursor_theme::apply();
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control = automation_args::ControlArg::default();
+338 -14
View File
@@ -1,29 +1,184 @@
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
//!
//! In order:
//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the
//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves
//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding
//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding
//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows
//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is
//! created, the settings in the user's profile are copied into it, so a portable zip that gains
//! the marker keeps its users' settings. If the folder can't be written (read-only medium,
//! Program Files), a warning is logged and the platform folder is used. A portable copy never
//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never
//! looks for the marker.
//! 3. The platform convention.
use std::ffi::OsString;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Files in the install folder that switch on portable mode (either one; contents are ignored).
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"];
/// The settings folder created in the install folder in portable mode.
pub const PORTABLE_DATA_DIR: &str = "VectorCraftData";
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir)
}
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
/// `portable` is the portable settings folder, if any: only asked for when no override is set and
/// preferences are in use.
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
}
// A run without preferences reads and writes nothing, so it must not create the portable folder.
if env("VECTORCRAFT_NO_PREFS").is_none()
&& let Some(dir) = portable()
{
// A portable copy is self-contained: DrawCraft's old profile is not read into it.
return (!legacy).then(|| dir.join("ui.json"));
}
platform_dir(os, legacy, &env).map(|p| p.join("ui.json"))
}
/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`).
fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
let base = match os {
match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
}
}
/// The portable settings folder of this copy, looked for once per process (it probes the disk, and
/// the settings path is asked for many times).
fn portable_dir() -> Option<PathBuf> {
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
DIR.get_or_init(|| {
let os = std::env::consts::OS;
let env = |key: &str| std::env::var_os(key);
let install = install_dir(os, &std::env::current_exe().ok()?, &env)?;
portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| {
// Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger
// keeps early records for the log file.
log::warn!("{warning}");
None
})
})
.clone()
}
/// The folder the user sees the app in, where the portable marker is looked for: the executable's;
/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a
/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when
/// `$APPIMAGE` names it (the executable itself runs from a read-only mount).
fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let exe_dir = exe.parent()?;
let dir = match os {
"macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(),
"windows" => exe_dir.to_path_buf(),
_ => {
let appimage = env("APPIMAGE").map(PathBuf::from);
let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty());
beside.unwrap_or(exe_dir).to_path_buf()
}
};
base.map(|p| p.join("ui.json"))
Some(dir)
}
/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`.
fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?;
let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?;
bundle.extension().filter(|ext| *ext == "app").and(bundle.parent())
}
/// `<install_dir>/VectorCraftData` when a portable marker sits in `install_dir`, created if missing;
/// `None` without a marker, and the warning to log when there is one but that folder can't be
/// written. The first time the folder is created, the settings in `previous` (the user's profile
/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its
/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it
/// is, and a later start finds the folder and copies nothing.
fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result<Option<PathBuf>, String> {
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) };
let data = install_dir.join(PORTABLE_DATA_DIR);
let first_start = !data.exists();
if let Err(e) = ensure_writable(&data) {
return Err(format!(
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead",
marker.display(),
data.display()
));
}
if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) {
match copy_dir(previous, &data) {
Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()),
Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()),
}
}
Ok(Some(data))
}
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
// A leftover probe is harmless; don't give up portable mode over it.
let _ = std::fs::remove_file(&probe);
Ok(())
}
/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be
/// copied; the error says how many were skipped and why the first one was.
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
let mut skipped = 0usize;
let mut first: Option<String> = None;
copy_dir_into(from, to, &mut |why| {
skipped += 1;
first.get_or_insert(why);
});
match first {
None => Ok(()),
Some(why) => Err(format!("{skipped} skipped, the first because {why}")),
}
}
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
let entries = match std::fs::read_dir(from) {
Ok(entries) => entries,
Err(e) => return skip(format!("{}: {e}", from.display())),
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
skip(format!("{}: {e}", from.display()));
continue;
}
};
let (src, dst) = (entry.path(), to.join(entry.file_name()));
// A symbolic link is copied as what it points to, or skipped when that is a folder.
let copied = match entry.file_type() {
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
Err(e) => Err(e),
};
if let Err(e) = copied {
skip(format!("{}: {e}", src.display()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn environment(key: &str) -> Option<OsString> {
match key {
@@ -34,33 +189,202 @@ mod tests {
}
}
/// No portable marker in the install folder.
fn installed() -> Option<PathBuf> {
None
}
#[test]
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
for os in ["macos", "windows", "linux"] {
let env = |key: &str| {
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
};
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env), None);
assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env, installed), None);
}
}
#[test]
fn empty_override_does_not_fall_back_to_user_files() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
assert_eq!(prefs_path_with("macos", false, env), None);
assert_eq!(prefs_path_with("macos", true, env), None);
assert_eq!(prefs_path_with("macos", false, env, installed), None);
assert_eq!(prefs_path_with("macos", true, env, installed), None);
}
#[test]
fn unset_override_keeps_platform_paths_and_legacy_migration() {
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed),
Some("/user/.config/drawcraft/ui.json".into())
);
}
#[test]
fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() {
let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData"));
for os in ["macos", "windows", "linux"] {
assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}");
// A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped.
assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}");
}
}
#[test]
fn the_config_dir_override_wins_over_portable_mode_without_probing_it() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) };
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") };
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with("windows", true, env, portable), None);
}
#[test]
fn a_run_without_preferences_never_looks_for_the_portable_folder() {
let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) };
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") };
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into()));
}
#[test]
fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() {
let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft");
assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into()));
// A bare executable (the CLI zip) and other platforms: the executable's own folder.
assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into()));
assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
}
#[test]
fn an_appimage_looks_for_the_marker_beside_the_appimage_file() {
let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft");
let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) };
assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into()));
assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into()));
let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) };
assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into()));
// The variable means nothing on other platforms.
assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
}
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn either_marker_beside_the_executable_makes_a_writable_data_folder() {
for marker in PORTABLE_MARKERS {
let exe = temp("portable");
std::fs::write(exe.join(marker), b"").unwrap();
let dir = portable_dir_in(&exe, None).unwrap().unwrap();
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}");
assert!(dir.is_dir(), "the data folder is created");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
let _ = std::fs::remove_dir_all(&exe);
}
}
#[test]
fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() {
let folder = temp("bundle");
let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft");
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::write(folder.join("portable.txt"), b"").unwrap();
let install = install_dir("macos", &exe, &environment).unwrap();
assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR))));
assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle");
// A marker dropped inside the bundle is not looked at.
std::fs::remove_file(folder.join("portable.txt")).unwrap();
std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap();
let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR));
assert_eq!(portable_dir_in(&install, None), Ok(None));
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn without_a_marker_file_there_is_no_portable_folder() {
let exe = temp("installed");
assert_eq!(portable_dir_in(&exe, None), Ok(None));
// A folder named like the marker is not a marker.
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
assert_eq!(portable_dir_in(&exe, None), Ok(None));
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker");
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn an_unwritable_portable_folder_is_reported_instead_of_used() {
let exe = temp("unwritable");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
// A file where the data folder should go: it can't be created, on every platform.
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
let warning = portable_dir_in(&exe, None).unwrap_err();
assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}");
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn the_first_start_copies_the_user_profile_into_the_data_folder_once() {
let exe = temp("upgrade");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
let previous = temp("previous-profile");
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap();
std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap();
std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap();
let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap();
assert_eq!(data, exe.join(PORTABLE_DATA_DIR));
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied");
assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied");
assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is");
assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is");
// A second start finds the folder and copies nothing, whatever changed on either side.
std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap();
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap();
std::fs::write(previous.join("later.txt"), b"").unwrap();
assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone())));
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten");
assert!(!data.join("later.txt").exists(), "nothing is copied after the first start");
let _ = std::fs::remove_dir_all(&exe);
let _ = std::fs::remove_dir_all(&previous);
}
#[test]
fn without_a_user_profile_the_data_folder_starts_empty() {
let exe = temp("fresh");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
let missing = exe.join("no-such-profile");
let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap();
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() {
let from = temp("copy-from");
let to = temp("copy-to");
std::fs::write(from.join("kept.txt"), b"kept").unwrap();
std::fs::create_dir_all(from.join("blocked")).unwrap();
std::fs::write(from.join("blocked/inner.txt"), b"").unwrap();
// A file where the sub-folder should go: that entry can't be copied, the others can.
std::fs::write(to.join("blocked"), b"in the way").unwrap();
let error = copy_dir(&from, &to).unwrap_err();
assert!(error.starts_with("1 skipped"), "{error}");
assert!(error.contains("blocked"), "{error}");
assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept");
let _ = std::fs::remove_dir_all(&from);
let _ = std::fs::remove_dir_all(&to);
}
}
+5
View File
@@ -115,3 +115,8 @@ sync_github_name = false
real_name = "Kamil Kwiatkowski"
display_name = "Kamil Kwiatkowski"
sync_github_name = false
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
+35
View File
@@ -965,6 +965,41 @@ fn drawings_the_export_writes_read_back() {
}
}
#[test]
fn closed_control_point_splines_stay_closed_through_export_and_import() {
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
let cases = [(true, true), (true, false), (false, true), (false, false)];
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
for (i, &(closed, returns)) in cases.iter().enumerate() {
let mut b = kurbo::BezPath::new();
b.move_to((20.0, 20.0));
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
if returns {
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
}
if closed {
b.close_path();
}
let id = doc.alloc_id();
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
}
for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) {
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
let out = crate::export(&doc, &opts).unwrap();
let text = std::str::from_utf8(&out.bytes).unwrap();
assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities");
let back = leaves(&open(&out.bytes, &points()).document);
assert_eq!(back.len(), cases.len(), "{version:?}");
for (n, &(closed, returns)) in back.iter().zip(&cases) {
let path = n.path_data().unwrap();
assert_eq!(path.subpaths.len(), 1, "{version:?}");
assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}");
}
}
}
#[test]
fn info_lists_layers_layouts_and_version() {
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
+24 -7
View File
@@ -171,9 +171,25 @@ impl GenericCmyk {
xyz_to_lab(self.to_xyz(cmyk))
}
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
/// With `bpc` (media-relative only) the press black displays as black, undoing the
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
/// darkest rich black shows as black again.
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
if absolute {
return self.to_xyz(cmyk);
}
let x = self.to_xyz_rel(cmyk);
if !bpc {
return x;
}
let bp = self.black_rel;
[0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0))
}
/// Display sRGB, see [`Self::display_xyz`].
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
}
/// Lab of the darkest printable black (media-relative).
@@ -425,11 +441,12 @@ impl GenericCmyk {
self.from_lab(srgb_to_lab(rgb), intent, bpc)
}
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
pub fn gamut_error(&self, src: Lab) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
/// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays
/// (with `bpc`, the press black showing as black).
pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc);
let cmyk = self.separate(target);
delta_e2000(self.to_lab_rel(cmyk), target)
delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src)
}
}
+16 -7
View File
@@ -346,10 +346,10 @@ impl Cms {
}
}
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
match space {
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc),
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
CmykSpace::Icc(p) => {
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
@@ -376,7 +376,7 @@ impl Cms {
{
return v;
}
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
}
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
@@ -408,6 +408,15 @@ impl Cms {
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
}
}
/// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in
/// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows
/// as black). Unclipped, for interpolating tables.
pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab {
match &self.cmyk {
CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)),
_ => self.cmyk_to_lab(cmyk),
}
}
/// Lab → working CMYK with `intent`.
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
match &self.cmyk {
@@ -429,7 +438,7 @@ impl Cms {
/// CIE Lab (D50) of any colour.
pub fn lab(&self, c: &Color) -> Lab {
match *c {
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]),
Color::Lab { l, a, b } => Lab::new(l, a, b),
_ => lab::srgb_to_lab(self.display_rgb(c)),
}
@@ -490,7 +499,7 @@ impl Cms {
Color::Rgb { .. } | Color::Lab { .. } => {
let src = self.lab(c);
match &self.cmyk {
CmykSpace::Generic(g) => g.gamut_error(src),
CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc),
CmykSpace::Device => 0.0,
CmykSpace::Icc(_) => {
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
@@ -523,7 +532,7 @@ impl Cms {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => {
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
}
Err(_) => srgb,
},
@@ -534,7 +543,7 @@ impl Cms {
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
match &proof.target {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
},
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
+14
View File
@@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() {
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
}
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
#[test]
fn generic_cmyk_black_round_trips_with_bpc() {
let c = cms();
let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8);
assert_eq!(back, [0, 0, 0], "{cmyk:?}");
// The paper still shows as white, and without compensation the press black stays a dark grey.
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false);
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
}
#[test]
fn lab_colours_round_trip_through_the_cms() {
let c = cms_no_bpc();
+2 -1
View File
@@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() {
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
let p = IccProfile::from_bytes(None, &bytes).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
// The encoded profile converts without black-point compensation, so compare without it.
let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap();
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
let want = cms.cmyk_to_srgb(cmyk, false);
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
+12
View File
@@ -181,6 +181,10 @@ impl Color {
_ => {}
}
let h = s.strip_prefix('#').unwrap_or(s);
// `from_str_radix` accepts a leading `+`, so check the digits first.
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
match h.len() {
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
@@ -412,6 +416,14 @@ mod tests {
assert_eq!(Color::from_hex("zz"), None);
}
#[test]
fn hex_rejects_sign_characters() {
for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] {
assert_eq!(Color::from_hex(bad), None, "{bad}");
}
assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233"));
}
#[test]
fn hsb_roundtrip() {
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
+12 -7
View File
@@ -430,17 +430,22 @@ impl GraphSpec {
let cells = self.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
let field = |c: &str, always: bool| {
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
format!("\"{}\"", c.replace('"', "\"\""))
} else {
c.to_string()
}
};
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !self.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
out.push_str(&format!("\"{c}\""));
} else {
out.push_str(c);
}
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
out.push(',');
}
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
+137
View File
@@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
tops
}
/// The most objects [`locked_objects_at`] lists.
pub const MAX_LOCKED_AT: usize = 20;
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
/// (`opt.scope`) only what is inside the isolated group or object.
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
let symbols = doc.symbol_boxes();
let mut out = Vec::new();
if let Some(scope) = opt.scope
&& let Some(isolated) = doc.node(scope)
{
if isolated.is_container() {
locked_children(isolated, p, opt, &symbols, &mut out);
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
out.push(isolated.id);
}
return out;
}
for layer in doc.layers.iter().rev() {
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
locked_children(layer, p, opt, &symbols, &mut out);
}
out
}
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
let Some(children) = parent.children() else { return };
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return;
}
for i in hit_order(parent, children, p) {
if out.len() >= MAX_LOCKED_AT {
return;
}
let Some(c) = children.get(i) else { continue };
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
continue;
}
if c.locked {
if locked_hit(c, p, opt, symbols) {
out.push(c.id);
}
continue;
}
match &c.kind {
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
NodeKind::Layer { .. }
| NodeKind::Group { .. }
| NodeKind::Intertwine { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
_ => {}
}
}
}
/// Would a click at `p` hit the locked object `n` if it were not locked?
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
match &n.kind {
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
_ => hit_leaf(n, p, opt, symbols).is_some(),
}
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
@@ -901,6 +975,69 @@ world",
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
}
/// Insert `n` locked at the top of `parent`.
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
let id = n.id;
n.locked = true;
d.insert(Some(parent), usize::MAX, n).unwrap();
id
}
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
/// Hidden objects, locked layers and the art outside an isolated group are left out.
#[test]
fn locked_objects_at_lists_the_locked_stack() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let rect =
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
inner.locked = true;
let inner_id = inner.id;
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
g.locked = true;
d.insert(Some(l), usize::MAX, g).unwrap();
let open_group = d.alloc_id();
let mut child = rect(&mut d, 25.0);
child.locked = true;
let child_id = child.id;
let free = rect(&mut d, 25.0);
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
assert_eq!(at(&d, 5.0, None), [a]);
assert!(at(&d, 150.0, None).is_empty());
// Isolated, only the group's own objects are looked at.
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
d.node_mut(b).unwrap().visible = false;
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
d.node_mut(l).unwrap().locked = true;
assert!(at(&d, 30.0, None).is_empty());
d.node_mut(l).unwrap().locked = false;
d.node_mut(l).unwrap().visible = false;
assert!(at(&d, 30.0, None).is_empty());
}
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
#[test]
fn locked_objects_at_stops_at_its_limit() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
.map(|_| {
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
insert_locked(&mut d, l, n)
})
.collect();
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
}
#[test]
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
let mut d = Document::new(200.0, 200.0);
+51 -3
View File
@@ -20,9 +20,11 @@ pub mod inks;
mod inline;
pub mod intertwine;
pub mod shared;
mod symbol_export_type;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
@@ -110,9 +112,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
TextWrap, VerticalAlign, WrapShape,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
@@ -1578,6 +1580,52 @@ impl Document {
o.insert(name.into(), serde_json::json!(reg.0));
}
}
/// Move the stored registration when a symbol is renamed.
pub fn rename_symbol_registration(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let reg = self.symbol_registration(from);
self.set_symbol_registration(from, SymbolRegistration::CENTER);
self.set_symbol_registration(to, reg);
}
/// Symbol Options › Export Type for `name` (default: Movie Clip).
pub fn symbol_export_type(&self, name: &str) -> SymbolExportType {
self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default()
}
/// Set or clear (when Movie Clip, the default) the export type stored for `name`.
pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) {
if kind.is_default() {
self.clear_symbol_export_type(name);
return;
}
let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({}));
let Some(o) = m.as_object_mut() else { return };
o.insert(name.into(), serde_json::json!(kind.id()));
}
/// Drop any stored export type for `name` (Movie Clip default).
pub fn clear_symbol_export_type(&mut self, name: &str) {
let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return };
let Some(o) = m.as_object_mut() else { return };
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_EXPORT_TYPE);
}
}
/// Move the stored export type when a symbol is renamed.
pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let kind = self.symbol_export_type(from);
self.clear_symbol_export_type(from);
self.set_symbol_export_type(to, kind);
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
+26
View File
@@ -1217,6 +1217,13 @@ impl Node {
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
NodeKind::Text(t) => {
// The characters' strokes paint around the glyphs, scaled with the type.
let k = Scaling::factor(t.xf).unwrap_or(1.0);
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
let o = self.appearance.outset().max(chars);
self.geometric(symbols).map(|b| b.inflate(o, o))
}
_ => {
let o = self.appearance.outset();
self.geometric(symbols).map(|b| b.inflate(o, o))
@@ -2336,6 +2343,25 @@ mod tests {
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
}
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
#[test]
fn type_visual_bounds_grow_with_the_character_stroke() {
use crate::appearance::StrokeAlign;
use crate::text::{CharStyle, TextObject};
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
t.xf = Affine::scale(2.0) * t.xf;
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
let g = node(&t).geometric_bounds().unwrap();
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
t.runs[0].style.stroke_align = align;
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
}
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
}
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
fn nested_group() -> Node {
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+9 -8
View File
@@ -94,26 +94,27 @@ impl Node {
}
}
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
/// printed plate this approximates their inks printing over the inks below. Darken keeps the
/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays
/// that colour, where Multiply would darken it. With `discard_white`
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
pub fn darken_overprints(a: &mut Arc<Node>, discard_white: bool) {
if !a.has_overprint() {
return;
}
let n = Arc::make_mut(a);
let multiplies =
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
for it in &mut n.appearance.items {
match it {
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
_ => {}
}
}
for c in n.children_mut().into_iter().flatten() {
multiply_overprints(c, discard_white);
darken_overprints(c, discard_white);
}
}
+33 -1
View File
@@ -16,7 +16,7 @@ pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 }
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) * 4 > max {
if u64::from(w) * u64::from(h) > max / 4 {
return None;
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
@@ -143,6 +143,38 @@ mod tests {
assert!(rgba_within(b"not an image", u64::MAX).is_none());
}
/// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds
/// no pixel data.
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"II*\0".to_vec();
out.extend(8u32.to_le_bytes());
// Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value.
let entries: [(u16, u16, u32); 9] =
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
out.extend((entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend(tag.to_le_bytes());
out.extend(kind.to_le_bytes());
out.extend(1u32.to_le_bytes());
out.extend(value.to_le_bytes());
}
out.extend(0u32.to_le_bytes());
out
}
/// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is
/// refused like any other past the allowance, and an image within the allowance still decodes.
#[test]
fn a_size_past_64_bits_as_rgba_is_not_decoded() {
let huge = tiff_header(1 << 31, 1 << 31);
assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none());
assert!(rgba_within(&huge, u64::MAX).is_none());
let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
let mut png = Vec::new();
gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35");
}
#[test]
fn samples_a_pixel_or_the_average_around_it() {
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
+49
View File
@@ -0,0 +1,49 @@
//! Symbol Options › Export Type: Graphic or Movie Clip
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
//!
//! For Illustrator-only work the choice has no canvas effect; it is kept for Animate / Flash /
//! HTML5 handoff. Illustrator's default is Movie Clip. Stored under [`SYMBOL_EXPORT_TYPE`] keyed
//! by symbol name; the default (Movie Clip) is omitted from the map.
use serde_json::Value;
/// Document unknown key: `{ symbolName: "graphic"|"movieClip" }`.
pub const SYMBOL_EXPORT_TYPE: &str = "symbolExportType";
/// Symbol Options › Export Type.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SymbolExportType {
/// Static / graphic symbol instance (Animate Graphic).
Graphic,
/// Default: Movie Clip for Animate / game handoff; instances still place the same on the canvas.
#[default]
MovieClip,
}
impl SymbolExportType {
pub const IDS: [&'static str; 2] = ["movieClip", "graphic"];
pub fn id(self) -> &'static str {
match self {
Self::Graphic => "graphic",
Self::MovieClip => "movieClip",
}
}
pub fn from_id(s: &str) -> Option<Self> {
match s {
"graphic" => Some(Self::Graphic),
"movieClip" => Some(Self::MovieClip),
_ => None,
}
}
/// True when this is the document default (nothing stored for the symbol).
pub fn is_default(self) -> bool {
matches!(self, Self::MovieClip)
}
pub fn from_value(v: &Value) -> Option<Self> {
v.as_str().and_then(Self::from_id)
}
}
+65 -5
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
@@ -84,6 +84,10 @@ pub struct CharStyle {
pub stroke_miter_limit: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_dash: Option<Dash>,
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub stroke_align: StrokeAlign,
/// Superscript or subscript (Character panel).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub position: CharPosition,
@@ -249,6 +253,7 @@ impl Default for CharStyle {
stroke_join: LineJoin::Miter,
stroke_miter_limit: 10.0,
stroke_dash: None,
stroke_align: StrokeAlign::Center,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
@@ -266,8 +271,9 @@ impl CharStyle {
pub fn has_stroke(&self) -> bool {
!self.stroke.is_none() && self.stroke_width > 0.0
}
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
/// and export.
pub fn stroke_layer(&self) -> StrokeLayer {
StrokeLayer {
overprint: self.overprint_stroke,
@@ -275,11 +281,13 @@ impl CharStyle {
join: self.stroke_join,
miter_limit: self.stroke_miter_limit,
dash: self.stroke_dash.clone(),
align: self.stroke_align,
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
}
}
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
/// character stroke (the options characters have no use for, such as arrowheads and width
/// profiles, are dropped).
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
self.stroke = st.paint.clone();
self.stroke_width = st.width;
@@ -287,6 +295,19 @@ impl CharStyle {
self.stroke_join = st.join;
self.stroke_miter_limit = st.miter_limit;
self.stroke_dash = st.dash.clone();
self.stroke_align = st.align;
}
/// How far the character stroke paints past the glyph outlines (text space): half the weight
/// centred, the whole weight outside, nothing inside (miter spikes aside).
pub fn stroke_outset(&self) -> f64 {
if !self.has_stroke() {
return 0.0;
}
match self.stroke_align {
StrokeAlign::Center => self.stroke_width / 2.0,
StrokeAlign::Outside => self.stroke_width,
StrokeAlign::Inside => 0.0,
}
}
/// The character fill as a fill layer, overprinting as the characters do.
fn fill_layer(&self) -> FillLayer {
@@ -360,6 +381,37 @@ impl Composer {
}
}
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
/// limit, no hyphenation zone, and capitalized words hyphenated.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HyphenationOptions {
/// Words Longer Than: only words with more letters than this are hyphenated.
pub words_longer_than: u32,
/// After First: the fewest letters a word keeps before the hyphen.
pub after_first: u32,
/// Before Last: the fewest letters a word keeps after the hyphen.
pub before_last: u32,
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
/// 0 sets no limit.
pub hyphen_limit: u32,
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
/// word already ends within this distance of its right edge breaks before that word instead
/// of hyphenating it. 0 allows every hyphenation.
pub zone: f64,
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
/// hyphenated. Words in capitals only are never hyphenated.
pub capitalized: bool,
}
impl Default for HyphenationOptions {
fn default() -> Self {
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
@@ -374,6 +426,8 @@ pub struct ParaStyle {
pub space_before: f64,
pub space_after: f64,
pub hyphenate: bool,
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
pub hyphenation: HyphenationOptions,
/// Line breaking: Single-line or Every-line Composer.
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
@@ -421,6 +475,8 @@ struct ParaStyleFile {
space_after: f64,
#[serde(default)]
hyphenate: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
hyphenation: HyphenationOptions,
#[serde(default, skip_serializing_if = "Composer::is_default")]
composer: Composer,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -454,6 +510,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -473,6 +530,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -495,6 +553,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -513,6 +572,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
+17 -12
View File
@@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String {
out
}
/// The XML id for an object name (the inverse of [`to_object_name`]).
/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty,
/// since a bare `_` would read back as a space.
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
for (i, c) in name.chars().enumerate() {
@@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
} else if i == 0 && c.is_ascii_digit() {
} else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') {
// A first character that cannot start an XML name is escaped, never given a bare `_`
// prefix (that would read back as a space).
out.push_str(&format!("_x{:X}_", u32::from(c)));
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
out.push(c);
} else {
out.push_str(&format!("_x{:X}_", u32::from(c)));
out.push(c);
}
}
if out.is_empty() || !starts_name(&out) {
out.insert(0, '_');
}
out
}
@@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool {
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
fn starts_name(id: &str) -> bool {
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
@@ -114,4 +108,15 @@ mod tests {
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
}
#[test]
fn round_trip_names_that_cannot_start_an_xml_name() {
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
let id = from_object_name(name);
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
}
assert_eq!(from_object_name("-abc"), "_x2D_abc");
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
assert_eq!(from_object_name("²x"), "_xB2_x");
}
}
+4 -3
View File
@@ -28,7 +28,7 @@ use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -106,9 +106,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = evaluate_container(&m).unwrap_or(m);
return Some(bake_pieces(d, m));
}
// Type, images, symbol instances and live objects: reshaped through their outlines. Type that
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
// Transform effects only move and copy stays type, moved to each place.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
+21 -2
View File
@@ -6,7 +6,7 @@
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
//! Unsharp Mask and Glowing Edges) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
//! the geometry.
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
@@ -149,6 +149,7 @@ fn lengths_of(id: &str) -> Lengths {
"texture.mosaicTiles" => always(&["tileSize"]),
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
"sketch.halftonePattern" => always(&["size"]),
_ => Lengths::default(),
}
}
@@ -165,6 +166,7 @@ const ARTISTIC: &[&str] = &["Effect", "Artistic"];
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
const DISTORT: &[&str] = &["Effect", "Distort"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
const SKETCH: &[&str] = &["Effect", "Sketch"];
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
const TEXTURE: &[&str] = &["Effect", "Texture"];
const VIDEO: &[&str] = &["Effect", "Video"];
@@ -289,6 +291,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
),
g(
"path.outlineObject",
"Outline Object",
PATH,
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change",
json!({}),
),
g(
"path.outlineStroke",
"Outline Stroke",
@@ -432,6 +441,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
),
r(
"sketch.halftonePattern",
"Halftone Pattern…",
SKETCH,
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
),
r(
"stylize.glowingEdges",
"Glowing Edges…",
@@ -865,6 +881,9 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
}
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
}
// Outline Object returns the geometry unchanged. Type reaches every geometry effect as its
// glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines.
"path.outlineObject" => path.clone(),
"path.outlineStroke" => {
let fallback;
let base = match ctx.stroke {
+17 -4
View File
@@ -1,7 +1,8 @@
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
//! Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//!
@@ -20,6 +21,7 @@ mod edges;
mod loaded;
mod pixelate;
mod sharpen;
mod sketch;
mod texture;
mod video;
@@ -33,10 +35,11 @@ pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use sketch::{HALFTONE_TYPES, HalftonePattern};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 28] = [
pub const PIXEL_EFFECTS: [&str; 29] = [
"artistic.filmGrain",
"blur.radial",
"blur.smart",
@@ -56,6 +59,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
"pixelate.mezzotint",
"pixelate.pointillize",
"sharpen.unsharpMask",
"sketch.halftonePattern",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
@@ -123,6 +127,9 @@ pub enum PixelFx {
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
@@ -277,6 +284,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
},
"sketch.halftonePattern" => PixelFx::HalftonePattern {
size: num(p, "size", 1.0).clamp(1.0, 12.0),
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
},
"stylize.glowingEdges" => PixelFx::GlowingEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
@@ -353,7 +365,7 @@ impl PixelFx {
// Content shifted outwards reaches past the bounds by as much as the shift.
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::FilmGrain { .. }
| PixelFx::ColorHalftone { .. }
@@ -396,7 +408,7 @@ impl PixelFx {
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
@@ -439,6 +451,7 @@ impl PixelFx {
}
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
+71
View File
@@ -0,0 +1,71 @@
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
use super::pixelate::Place;
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, pick};
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
/// A Halftone Pattern screen ([`HALFTONE_TYPES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HalftonePattern {
/// Rings round the object's centre.
Circle,
/// Dots on a square grid.
Dot,
/// Horizontal lines.
Line,
}
impl HalftonePattern {
const ALL: [HalftonePattern; 3] = [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line];
/// The screen of Pattern Type `value` (a [`HALFTONE_TYPES`] value, any case); Dot otherwise.
pub fn parse(value: &str) -> Self {
pick(&HALFTONE_TYPES, &Self::ALL, value, HalftonePattern::Dot)
}
}
/// Halftone Pattern: the object redrawn in black ink on white paper through a `kind` screen whose
/// cells are `size` points apart, anchored at the object's centre. The ink covers as much of a
/// cell as the colour is dark; `contrast` (0..50) hardens the screen's soft edges, to pure black
/// and white at 50. Coverage is kept.
pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, contrast: f64, kind: HalftonePattern) {
if !(size.is_finite() && size > 0.0 && contrast.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
// The width of the screen's soft edge, in tones: 0 is a hard threshold.
let soft = (0.5 * (1.0 - contrast / 50.0)).clamp(0.0, 0.5);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let band = |v: f64| (2.0 * ((v / size).rem_euclid(1.0) - 0.5)).abs();
// Where in its cell the pixel lies, 0 where the ink starts to 1 where it ends.
let screen = match kind {
HalftonePattern::Line => band(q.y),
HalftonePattern::Circle => band(q.x.hypot(q.y)),
HalftonePattern::Dot => 0.5 * (band(q.x).powi(2) + band(q.y).powi(2)),
};
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
let ink = if dark >= 1.0 {
1.0
} else if soft > 0.0 {
// The threshold kept inside [soft / 2, 1 - soft / 2], so white stays white and black black.
let threshold = 0.5 * soft + screen * (1.0 - soft);
((dark - threshold) / soft + 0.5).clamp(0.0, 1.0)
} else if dark > screen {
1.0
} else {
0.0
};
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
});
}
+116
View File
@@ -1893,3 +1893,119 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
assert_eq!(shifted, one, "{id}");
}
}
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.halftonePattern", p).apply(&mut out, w, h, s);
out
}
/// The smallest period of `values` (their length when they don't repeat).
fn period(values: &[u8]) -> usize {
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
}
#[test]
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
assert_eq!(
fx("sketch.halftonePattern", json!({"size": 1e9, "contrast": -3, "patternType": "LINE"})),
PixelFx::HalftonePattern { size: 12.0, contrast: 0.0, kind: HalftonePattern::Line }
);
assert_eq!(
fx("sketch.halftonePattern", json!({"size": 0, "contrast": 1e9, "patternType": "circle"})),
PixelFx::HalftonePattern { size: 1.0, contrast: 50.0, kind: HalftonePattern::Circle }
);
for junk in [json!({"patternType": 2}), json!({"patternType": "nonsense"})] {
assert_eq!(fx("sketch.halftonePattern", junk), fx("sketch.halftonePattern", json!({})));
}
let doc = crate::effect_info("sketch.halftonePattern").unwrap().params;
let kinds: Vec<HalftonePattern> = HALFTONE_TYPES.iter().map(|(_, v)| HalftonePattern::parse(v)).collect();
assert_eq!(kinds, [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]);
assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
let f = fx("sketch.halftonePattern", json!({"size": 12}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
}
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
#[test]
fn halftone_line_period_is_the_size_at_any_resolution() {
let p = json!({"size": 4, "contrast": 50, "patternType": "line"});
let (w, h) = (48, 48);
let out = halftone(p.clone(), &image(w, h, |_, _| grey(128)), w, h, &space(w, h));
let column: Vec<u8> = (0..h).map(|y| at(&out, w, 0, y)[0]).collect();
assert!(column.contains(&0) && column.contains(&255), "{column:?}");
assert_eq!(period(&column), 4, "{column:?}");
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
let out = halftone(p, &image(2 * w, 2 * h, |_, _| grey(128)), 2 * w, 2 * h, &fine);
let column: Vec<u8> = (0..2 * h).map(|y| at(&out, 2 * w, 0, y)[0]).collect();
assert_eq!(period(&column), 8, "{column:?}");
}
/// Circle draws rings round the object's centre, Line rows that don't change along them, and
/// Dot neither.
#[test]
fn halftone_pattern_type_changes_the_pattern() {
let (w, h) = (64, 64);
let src = image(w, h, |_, _| grey(100));
// The centre on a pixel's centre: pixel (32 + dx, 32 + dy) lies at (dx, dy) from it.
let s = PixelSpace { center: Point::new(32.5, 32.5), ..space(w, h) };
let draw = |kind: &str| halftone(json!({"size": 8, "contrast": 50, "patternType": kind}), &src, w, h, &s);
let (circle, dot, line) = (draw("circle"), draw("dot"), draw("line"));
assert!(circle != dot && dot != line && line != circle);
// Pixels at the same distance from the centre in different directions.
let same_radius: [&[(i64, i64)]; 6] = [
&[(5, 0), (3, 4), (-4, 3), (0, -5), (-3, -4)],
&[(10, 0), (6, 8), (-8, -6), (0, 10)],
&[(13, 0), (5, 12), (-12, 5), (0, -13)],
&[(15, 0), (9, 12), (-12, 9)],
&[(17, 0), (8, 15), (-15, -8)],
&[(25, 0), (7, 24), (-15, 20), (-20, -15), (24, -7)],
];
let value = |d: &[u8], (dx, dy): (i64, i64)| at(d, w, (32 + dx) as usize, (32 + dy) as usize)[0];
let radial = |d: &[u8]| same_radius.iter().all(|ring| ring.iter().all(|q| value(d, *q) == value(d, ring[0])));
assert!(radial(&circle), "Circle is the same all round");
assert!(!radial(&dot) && !radial(&line));
for y in 0..h {
assert!((0..w).all(|x| at(&line, w, x, y) == at(&line, w, 0, y)), "row {y} of Line is uniform");
}
}
#[test]
fn halftone_contrast_sharpens_the_screen() {
let (w, h) = (64, 32);
let src = image(w, h, |x, _| grey((x * 4) as u8));
let mid = |d: &[u8]| share(d, |p| p[0] != 0 && p[0] != 255);
let soft = halftone(json!({"size": 6, "contrast": 0}), &src, w, h, &space(w, h));
let hard = halftone(json!({"size": 6, "contrast": 50}), &src, w, h, &space(w, h));
assert!(mid(&soft) > 0.3, "{}", mid(&soft));
assert_eq!(mid(&hard), 0.0);
}
/// Halftone Pattern redraws in black ink on white paper, whatever the object's colours, and
/// keeps its coverage.
#[test]
fn halftone_draws_in_black_and_white_only() {
let (w, h) = (48, 48);
let src = image(w, h, |x, y| {
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
let a: u16 = if y < 24 { 255 } else { 128 };
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
});
for kind in ["circle", "dot", "line"] {
let out = halftone(json!({"size": 6, "contrast": 50, "patternType": kind}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "{kind}: coverage kept");
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{kind}: {p:?} from {q:?}");
}
assert!(share(&out, |p| p[0] == 0) > 0.1 && share(&out, |p| p[0] == p[3]) > 0.1, "{kind}: ink and paper");
let soft = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &src, w, h, &space(w, h));
assert!(soft.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{kind}: greys only");
for (tone, expect) in [(0, 0), (255, 255)] {
let out = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &image(w, h, |_, _| grey(tone)), w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == expect), "{kind}: {tone} stays {expect}");
}
}
}
+17 -5
View File
@@ -5,7 +5,9 @@
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints.
use std::sync::Arc;
@@ -30,6 +32,14 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
/// paints ([`outline_text`], which leaves inline graphics out).
pub fn has_item_geometry(n: &Node) -> bool {
let NodeKind::Text(t) = &n.kind else { return false };
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
}
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
/// path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
@@ -155,11 +165,13 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
/// perspective, or has no art (a symbol instance without `symbol_art`).
/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
/// symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) {
return None;
}
as_art(n, symbol_art)
+12
View File
@@ -572,6 +572,18 @@ fn outline_stroke_uses_context_stroke() {
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
}
#[test]
fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
let info = effect_info("path.outlineObject").unwrap();
assert_eq!((info.label, info.menu, &info.defaults, info.raster), ("Outline Object", &["Effect", "Path"][..], &json!({}), false));
assert!(is_geometry("path.outlineObject"));
assert_eq!(run("path.outlineObject", json!({}), &square()), square());
// The effects after it reshape the same geometry.
let b = square().bounds().unwrap();
let offset = fx("path.offsetPath", json!({"offset": 5}));
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
}
#[test]
fn convert_to_shape_sizes() {
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
+1 -1
View File
@@ -80,7 +80,7 @@ fn clean_name(name: &str) -> Option<String> {
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
/// (sublayers unfolded). Layers alone have nothing to collect.
fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = doc.node(id) else { return };
if n.is_layer() {
if let Some(kids) = n.children() {
+96 -9
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, Brush};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::{outermost, selected_roots};
@@ -95,16 +95,25 @@ pub fn specs() -> Vec<CommandSpec> {
brush_freehand
),
// ----- symbols -----
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8}], current}", has_doc, symbol_list),
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list),
cmd!(
"symbol.new",
"New Symbol…",
[],
None,
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration)} make a symbol from the selection and replace it with an instance → {name, id, registration}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
has_selection,
symbol_new
),
cmd!(
"symbol.options",
"Symbol Options…",
[],
None,
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
has_doc,
symbol_options
),
cmd!(
"symbol.place",
"Place Symbol Instance",
@@ -517,10 +526,30 @@ fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
}
fn registration_param(p: &Value, cmd: &str) -> Result<SymbolRegistration> {
fn registration_param(p: &Value, cmd: &str) -> Result<Option<SymbolRegistration>> {
match p.get("registration") {
None => Ok(SymbolRegistration::CENTER),
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).ok_or_else(|| bad(cmd, "registration must be 0..8")),
None => Ok(None),
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")),
}
}
fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
match str_param(p, "exportType") {
None => Ok(None),
Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))),
}
}
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
if from == to {
return;
}
let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned();
set_natural_size(d, from, None);
if let Some(v) = size
&& let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64))
{
set_natural_size(d, to, Some((w, h)));
}
}
@@ -566,6 +595,7 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
"instances": instances_of(d, Some(&sym.name)).len(),
"size": [w, h],
"registration": d.symbol_registration(&sym.name).0,
"exportType": d.symbol_export_type(&sym.name).id(),
})
})
.collect();
@@ -589,11 +619,13 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
}
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.new";
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (art, size, c) = normalize(art)?;
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let reg = registration_param(p, "symbol.new")?;
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
let export = export_type_param(p, C)?.unwrap_or_default();
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
let id = s.edit("New Symbol", |d, sel| {
@@ -601,12 +633,64 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
set_natural_size(d, &name, Some(size));
d.set_symbol_registration(&name, reg);
d.set_symbol_export_type(&name, export);
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let id = replace_with_instance(d, &ids, &name, place)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "name": name, "id": id.0, "registration": reg.0 }))
Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() }))
}
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.options";
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
let export = export_type_param(p, C)?;
let reg = registration_param(p, C)?;
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if new_name.is_none() && export.is_none() && reg.is_none() {
return Err(bad(C, "pass newName, registration and/or exportType"));
}
if let Some(ref n) = new_name
&& n != &name
&& s.doc()?.doc.symbols.iter().any(|x| x.name == *n)
{
return Err(bad(C, format!("a symbol named `{n}` already exists")));
}
let final_name = new_name.clone().unwrap_or_else(|| name.clone());
s.edit("Symbol Options", |d, _| {
if let Some(ref n) = new_name
&& n != &name
{
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
sym.name = n.clone();
}
for id in instances_of(d, Some(&name)) {
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) {
*symbol = n.clone();
}
}
rename_natural_size(d, &name, n);
d.rename_symbol_registration(&name, n);
d.rename_symbol_export_type(&name, n);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
}
}
if let Some(r) = reg {
d.set_symbol_registration(&final_name, r);
}
if let Some(kind) = export {
d.set_symbol_export_type(&final_name, kind);
}
Ok(())
})?;
let d = &s.doc()?.doc;
Ok(json!({
"name": final_name,
"registration": d.symbol_registration(&final_name).0,
"exportType": d.symbol_export_type(&final_name).id(),
}))
}
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
@@ -763,6 +847,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
d.symbols.retain(|x| x.name != name);
set_natural_size(d, &name, None);
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
d.clear_symbol_export_type(&name);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.remove(CURRENT_SYMBOL);
}
@@ -779,14 +864,16 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let export = d.symbol_export_type(&name);
s.edit("Duplicate Symbol", |d, _| {
let art = d.reid(&sym.art);
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
set_natural_size(d, &new, Some(size));
d.set_symbol_registration(&new, reg);
d.set_symbol_export_type(&new, export);
Ok(())
})?;
Ok(json!({ "name": new }))
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
}
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
+124 -12
View File
@@ -100,6 +100,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
save_selection
),
cmd!(
"select.update",
"Update Selection",
["Select"],
None,
"{} make the saved selection last recalled (`select.recall`) hold the selected objects instead of its own, in one undo step → {name}",
has_recalled_selection,
update_selection
),
cmd!(
"select.recall",
"Recall Selection",
@@ -135,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release Guides",
["View", "Guides"],
Some("Cmd+Alt+5"),
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
"{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}",
has_doc,
release_guides
),
@@ -342,6 +351,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
if existing.is_none() && saved.len() >= SavedSelection::MAX {
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
}
let mut slots = saved_ids(s)?;
s.edit("Save Selection", |d, _| {
let entry = SavedSelection { name: name.clone(), objects: ids };
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
@@ -350,18 +360,26 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
let st = s.doc_mut()?;
if existing.is_none() {
slots.push(st.recalled_selection.new_id());
}
st.recalled_selection.remember(&st.doc.saved_selections, slots);
Ok(json!({ "name": name }))
}
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.recall";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
let saved = &s.doc()?.doc.saved_selections;
let i = saved.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
let ids = saved.get(i).map(|x| x.objects.clone()).unwrap_or_default();
let st = s.doc()?;
let can = selectable_ids(&st.doc, st.isolation);
s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?;
Ok(json!({ "count": s.doc()?.selection.len() }))
let st = s.doc_mut()?;
st.recalled_selection.recall(&st.doc.saved_selections, i);
Ok(json!({ "count": st.selection.len() }))
}
/// Recall and Edit Selection… need a document with at least one saved selection.
@@ -370,6 +388,42 @@ fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
}
/// The session ids of the active document's saved selections ([`crate::RecalledSelection`]).
fn saved_ids(s: &mut Session) -> Result<Vec<u64>> {
let st = s.doc_mut()?;
Ok(st.recalled_selection.ids(&st.doc.saved_selections))
}
/// Where the saved selection last recalled is, while the document has it.
fn recalled_index(st: &crate::DocState) -> Option<usize> {
st.recalled_selection.index_in(&st.doc.saved_selections)
}
/// Update Selection needs selected objects and a recalled saved selection the document still has.
fn has_recalled_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
if recalled_index(st).is_some() { Ok(()) } else { Err("no saved selection recalled".into()) }
}
fn update_selection(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "select.update";
let st = s.doc()?;
let i = recalled_index(st).ok_or_else(|| bad(C, "no saved selection recalled"))?;
let ids = st.selection.objects.clone();
let name = st.doc.saved_selections.get(i).map(|x| x.name.clone()).unwrap_or_default();
let slots = saved_ids(s)?;
s.edit("Update Selection", |d, _| {
if let Some(x) = d.saved_selections.get_mut(i) {
x.objects = ids;
}
Ok(())
})?;
let st = s.doc_mut()?;
st.recalled_selection.remember(&st.doc.saved_selections, slots);
Ok(json!({ "name": name }))
}
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.editSaved";
let edits = match p.get("edits").and_then(Value::as_array) {
@@ -396,6 +450,7 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
x.name = n;
}
}
let slots: Vec<u64> = saved_ids(s)?.into_iter().zip(&after).filter(|(_, x)| x.is_some()).map(|(id, _)| id).collect();
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
let mut names = std::collections::HashSet::with_capacity(after.len());
for x in &after {
@@ -408,6 +463,8 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
d.saved_selections = after.into();
Ok(())
})?;
let st = s.doc_mut()?;
st.recalled_selection.remember(&st.doc.saved_selections, slots);
}
ok()
}
@@ -465,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
v
}
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected
/// guide paths and ruler guides, else every guide path, in one undo step.
fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.guides.release";
let (index, ids) = (p.get("index"), p.get("ids"));
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
return Err(EngineError::Other("guides are locked".into()));
}
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
let rulers = match index {
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
None => vec![],
};
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
let d = &s.doc()?.doc;
let mut paths = vec![];
for r in roots {
if let Some(n) = d.node(r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
paths.push(c.id);
}
});
}
}
(rulers, paths)
} else {
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let rulers: Vec<usize> = if s.menu.guides_locked {
vec![]
} else {
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
};
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
};
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
if targets.is_empty() {
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
let mut lines = Vec::with_capacity(rulers.len());
for &i in &rulers {
let Some(g) = st.doc.guides.get(i) else { continue };
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
let Some((a, b)) = span else { continue };
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
Some(l) => Some(l),
None => st.target_parent()?,
};
lines.push((i, parent, a, b));
}
let n = paths.len() + lines.len();
if n == 0 {
return Err(EngineError::Other("Release Guides: no guides".into()));
}
let n = targets.len();
s.edit("Release Guides", |d, sel| {
for id in &targets {
for id in &paths {
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*guide = false;
}
}
sel.set(targets.iter().copied());
let mut made = Vec::with_capacity(lines.len());
for &(_, parent, a, b) in &lines {
let id = d.alloc_id();
let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
made.push(id);
}
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
sel.set(paths.iter().copied().chain(made));
Ok(())
})?;
Ok(json!({ "count": n }))
+54 -10
View File
@@ -1,6 +1,7 @@
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
//! blob brush, smooth, path eraser, join).
use std::collections::BTreeSet;
use std::sync::Arc;
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
@@ -12,7 +13,8 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R
use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
use super::create::{add_look, add_node, insert_look};
use super::create::{add_look, insert_look};
use super::newart::NewArt;
use super::pathops::{Beside, remove_each};
use super::*;
use crate::EngineError;
@@ -60,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Anchor Points",
["Object", "Path"],
None,
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}",
has_anchors,
remove_anchors
),
@@ -141,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blob Brush",
[],
None,
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
has_doc,
blob
),
@@ -772,7 +774,16 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let mut removed = 0;
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, anchors) in jobs {
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
let shape: Vec<(usize, bool)> =
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
let next = following_anchors(&shape, &anchors);
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
removed += 1;
} else if !next.is_empty() {
// The next anchor along is selected, so the next Remove Anchor Points goes on from
// there without picking it first (#2241).
sel.anchors.insert(id, next);
}
}
emptied.finish(d)?;
Ok(removed)
@@ -780,6 +791,30 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "removedObjects": removed }))
}
/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a
/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its
/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's
/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors.
fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
let mut next = BTreeSet::new();
let mut kept = 0;
for (si, &(n, closed)) in shape.iter().enumerate() {
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
if n - gone.len() < 2 {
continue;
}
if let Some(&last) = gone.last() {
let wrap = if closed { 0..last } else { 0..0 };
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
next.insert((kept, a - gone.range(..a).count()));
}
}
kept += 1;
}
next
}
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
/// through `emptied`. True when the path went.
@@ -1312,11 +1347,14 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
} else {
Paint::solid(Color::BLACK)
};
let keep_selected = bool_or(p, "keepSelected", false);
let only_selection = bool_or(p, "mergeOnlySelection", false);
let st = s.doc()?;
let mut merge_ids = vec![];
if bool_or(p, "merge", true) {
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
let all = Selection::default();
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
let from = if only_selection { &st.selection } else { &all };
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
let Some(n) = st.doc.node(id) else { continue };
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
continue;
@@ -1327,20 +1365,26 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let ap = Appearance::basic(paint, Paint::None, 1.0);
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
if merge_ids.is_empty() {
return add_node(s, "Blob Brush", path_kind(region), ap, None);
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
}
let mut inputs: Vec<(&PathData, FillRule)> = vec![(&region, FillRule::NonZero)];
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
let merged = po::unite_all(&inputs);
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
};
let keep = *keep;
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
s.edit("Blob Brush", |d, sel| {
*path_mut(d, keep)? = merged;
remove_each(d, &others)?;
sel.set([keep]);
if keep_selected {
sel.set([keep]);
} else {
sel.remove(keep);
}
Ok(())
})?;
Ok(json!({ "id": keep.0 }))
+14 -1
View File
@@ -324,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
}
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
}
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
roots_within(doc, ids, None)
}
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
let paths = doc.index_paths(ids.iter().copied());
// A compound-path member acts as its compound (compounds are one object).
@@ -336,7 +349,7 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
let path = paths.get(&id);
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())),
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
_ => (id, path.cloned()),
}
})
+12 -3
View File
@@ -1,5 +1,5 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -108,6 +108,8 @@ struct Expandable {
placed: bool,
/// Repeats (Object expands them into groups of their instances).
repeats: bool,
/// Intertwine groups (Object expands them into groups of what they draw).
intertwines: bool,
}
impl Expandable {
@@ -123,6 +125,7 @@ impl Expandable {
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@@ -134,7 +137,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
}
}
@@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
}
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
// over the others, expand below.
if object && e.intertwines {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
}
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
+3 -1
View File
@@ -5,7 +5,9 @@
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
use serde_json::{Value, json};
use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use super::super::*;
+38
View File
@@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
}
}
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
/// contradict it). The image size limits are checked with the scale, as for an artboard.
fn page_rect(f: &Format, p: &Value) -> Result<Option<Rect>> {
let v = match p.get("rect") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
if !f.raster {
return Err(bad(C, format!("rect applies to raster formats, not {}", f.label)));
}
if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards")));
}
let nums: Option<Vec<f64>> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect());
let Some(&[x, y, w, h]) = nums.as_deref() else {
return Err(bad(C, "rect is [x, y, width, height] in points"));
};
let (x1, y1) = (x + w, y + h);
if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) {
return Err(bad(C, "rect needs finite numbers and a positive width and height"));
}
Ok(Some(Rect::new(x, y, x1, y1)))
}
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
with_single_artboard(doc.clone(), rect, name)
@@ -408,6 +434,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("rect");
o.remove("useArtboards");
}
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
}
if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
@@ -479,6 +514,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
} else {
bytes
};
// PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false.
let bytes =
if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes };
enc.files.push((Some(b), bytes));
}
if enc.files.is_empty() {
+16 -8
View File
@@ -94,11 +94,12 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
}
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
/// out, at any depth of layers, sublayers and groups that don't clip, and objects that reach it
/// stay whole (the file's bounds clip them), as DXF's crop leaves them. Symbol instances are
/// measured by their art, and the box of one whose art holds brushed strokes grows by as far as
/// their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol
/// instance also stays when what the canvas paints for it reaches `rect`
/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer
/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it.
/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them.
/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
/// its own art, not a whole document of artboards.
@@ -122,8 +123,9 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
let mut holds_instance = false;
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
}
let symbols = doc.symbol_boxes().with_brush_art();
let mut d = doc.clone();
@@ -165,13 +167,19 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Co
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
/// visual bounds.
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = selected_artwork(s)?;
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
}
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
/// layers are guides and are left out.
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
}
/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what
+5
View File
@@ -377,6 +377,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
doc.template = false;
doc.title = s.next_untitled();
}
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
// closed, its top-level layers as its groups (#1475). Ours carry their own.
if matches!(format.id, "ai" | "ait") && !restored && doc.layers_open.is_none() {
doc.layers_open = Some(vec![]);
}
// Save writes the file back only in a format it writes, only a whole PDF (see
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
// writes .ai that way).
+52 -8
View File
@@ -34,6 +34,7 @@ mod legacy_ai;
mod load;
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
mod metafile;
mod naming;
mod native;
pub mod pdf;
mod pdfimport;
@@ -63,6 +64,7 @@ pub use load::{
read_for_open, texture_image,
};
pub use load::{losses_summary, overwrite_losses};
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
@@ -71,7 +73,10 @@ pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub use screens::{
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
};
@@ -118,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -136,10 +141,37 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
cmd!(
query "document.exportPresets.list",
"Export Presets",
[],
None,
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
always,
screens::presets_list
),
cmd!(
query "document.exportPresets.save",
"Save Export Preset",
[],
None,
"{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, antiAlias, folder, fullDocument} (as document.exportForScreens; other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused → {name, created, settings}",
always,
screens::presets_save
),
cmd!(
query "document.exportPresets.delete",
"Delete Export Preset",
[],
None,
"{name} delete a saved batch export preset (built-in ones stay) → {deleted}",
always,
screens::presets_delete
),
cmd!(
query "command.batch",
"Batch",
@@ -279,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
default: "null",
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
};
const RECT: FormatOption = FormatOption {
name: "rect",
ty: "array",
default: "null",
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
};
const PPI: FormatOption = FormatOption {
name: "ppi",
ty: "number",
@@ -316,7 +354,7 @@ const EMBED_ICC: FormatOption = FormatOption {
name: "embedIcc",
ty: "boolean",
default: "true",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
};
const IMAGE_MAP: FormatOption = FormatOption {
name: "imageMap",
@@ -366,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -387,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
};
const TIFF_OPTIONS: &[FormatOption] =
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
const BMP_COLOR_MODEL: FormatOption =
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
const BMP_DEPTH: FormatOption = FormatOption {
@@ -415,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -434,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
default: "24",
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
};
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const PSD_LAYERS: FormatOption = FormatOption {
name: "layers",
ty: "boolean",
@@ -458,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -528,7 +569,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
},
Format {
id: "jpg",
@@ -543,6 +584,7 @@ pub const FORMATS: &[Format] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -564,7 +606,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
},
Format {
id: "tiff",
@@ -1143,6 +1185,8 @@ mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
#[cfg(test)]
mod tests_batch;
#[cfg(test)]
mod tests_screens;
#[cfg(test)]
+159
View File
@@ -0,0 +1,159 @@
//! File-name templates for batch exports (`document.exportForScreens {name}`): the same pattern
//! gives every exported file a consistent name, such as `COMPANY-Logo-<filename>-####-YYYYMMDD`.
//!
//! Tokens:
//! - `<filename>` (or `<document>`): the document's name without its extension;
//! - `<name>` (or `<artboard>`, `<item>`): the artboard's, asset's, layer's or selection's name;
//! - `<index>`: the item's place in the export, from 1;
//! - a run of `#`: the counter, zero-padded to the run's length (`####` → `0001`);
//! - `<format>` (or `<ext>`): the file's extension; `<scale>`: the row's size (`2x`, `100w`);
//! `<suffix>`: the row's suffix (otherwise added at the end);
//! - the date and time: `YYYYMMDD`, `YYYY-MM-DD` and `YYYY` as written, and `<date>`
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
//!
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
/// What a template's tokens stand for, for one file.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NameParts<'a> {
pub filename: &'a str,
pub name: &'a str,
pub index: usize,
pub counter: u64,
pub format: &'a str,
pub scale: &'a str,
pub suffix: &'a str,
/// `[year, month, day, hour, minute, second]`.
pub when: [i64; 6],
}
/// The bracketed tokens and what each stands for.
fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
let [y, mo, d, h, mi, s] = p.when;
Some(match name {
"filename" | "document" => p.filename.to_string(),
"name" | "artboard" | "item" => p.name.to_string(),
"index" => p.index.to_string(),
"format" | "ext" => p.format.to_string(),
"scale" => p.scale.to_string(),
"suffix" => p.suffix.to_string(),
"date" => format!("{y:04}{mo:02}{d:02}"),
"time" => format!("{h:02}{mi:02}{s:02}"),
"YYYY" => format!("{y:04}"),
"MM" => format!("{mo:02}"),
"DD" => format!("{d:02}"),
"hh" => format!("{h:02}"),
"mm" => format!("{mi:02}"),
"ss" => format!("{s:02}"),
_ => return None,
})
}
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
fn clean(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
pub fn has_suffix_token(template: &str) -> bool {
template.contains("<suffix>")
}
/// The file name (without extension) `template` gives for `p`.
pub fn render(template: &str, p: &NameParts<'_>) -> String {
let mut t = template.trim();
for ext in [".ext", ".<ext>", ".<format>"] {
if let Some(stripped) = t.strip_suffix(ext) {
t = stripped;
}
}
let [y, mo, d, ..] = p.when;
let mut out = String::with_capacity(t.len() + 16);
let mut rest = t;
while let Some(c) = rest.chars().next() {
// Bracketed tokens; an unknown one stays as written.
if c == '<'
&& let Some(end) = rest.find('>')
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
{
out.push_str(&clean(&value));
rest = rest.get(end + 1..).unwrap_or("");
continue;
}
if c == '#' {
let run = rest.chars().take_while(|c| *c == '#').count();
out.push_str(&format!("{:0run$}", p.counter));
rest = rest.get(run..).unwrap_or("");
continue;
}
// The date spelled out, longest first.
let date = [("YYYYMMDD", format!("{y:04}{mo:02}{d:02}")), ("YYYY-MM-DD", format!("{y:04}-{mo:02}-{d:02}")), ("YYYY", format!("{y:04}"))];
if let Some((pat, value)) = date.iter().find(|(pat, _)| rest.starts_with(pat)) {
out.push_str(value);
rest = rest.get(pat.len()..).unwrap_or("");
continue;
}
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
rest = rest.get(c.len_utf8()..).unwrap_or("");
}
out.trim().to_string()
}
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
pub fn parse_date(s: &str) -> Option<[i64; 6]> {
let digits: String = s.trim().chars().filter(|c| *c != '-').collect();
if digits.len() != 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let num = |a: usize, b: usize| digits.get(a..b).and_then(|t| t.parse::<i64>().ok());
let (y, m, d) = (num(0, 4)?, num(4, 6)?, num(6, 8)?);
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some([y, m, d, 0, 0, 0])
}
#[cfg(test)]
mod tests {
use super::*;
fn parts() -> NameParts<'static> {
NameParts { filename: "Brand", name: "Dark", index: 3, counter: 7, format: "png", scale: "2x", suffix: "@2x", when: [2026, 10, 16, 9, 5, 0] }
}
#[test]
fn the_example_from_the_request() {
assert_eq!(render("COMPANY-Logo-<filename>-####-YYYYMMDD.ext", &parts()), "COMPANY-Logo-Brand-0007-20261016");
}
#[test]
fn every_token() {
let p = parts();
assert_eq!(render("<document>_<name>_<artboard>_<item>", &p), "Brand_Dark_Dark_Dark");
assert_eq!(render("<index>-#-##-<format>-<ext>-<scale><suffix>", &p), "3-7-07-png-png-2x@2x");
assert_eq!(render("<date>T<time> <YYYY>/<MM>/<DD> <hh>:<mm>:<ss>", &p), "20261016T090500 2026-10-16 09-05-00");
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
// Words are left alone: only the date patterns and bracketed tokens are replaced.
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
}
#[test]
fn no_name_climbs_out_of_its_folder() {
let p = NameParts { name: "../../etc", ..parts() };
let n = render("a/b\\c:<name>", &p);
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
// A long counter is padded, a short run is not cut.
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
}
#[test]
fn dates_parse() {
assert_eq!(parse_date("2026-10-16"), Some([2026, 10, 16, 0, 0, 0]));
assert_eq!(parse_date("20261016"), Some([2026, 10, 16, 0, 0, 0]));
for bad in ["", "2026-13-01", "2026-1-1", "today", "2026-10-1x"] {
assert_eq!(parse_date(bad), None, "{bad}");
}
}
}
+387 -83
View File
@@ -1,20 +1,25 @@
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
//! whole document, or the chosen assets of the Asset Export panel) in every format row at each
//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP);
//! the presets the dialog offers; and the settings the document remembers
//! whole document, the chosen assets of the Asset Export panel, layers, or the selection) in every
//! format row at each row's size, into a folder and its sub-folders, or returned for download (each
//! file, or one ZIP); the presets the dialog offers; and the settings the document remembers
//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]).
//!
//! A batch export names every file from one pattern (`name`, see [`super::naming`]: the document's
//! and the item's names, a counter, the date), and sorts the files into folders by row, by format,
//! by item, or by item and format (`folders`).
use std::collections::HashSet;
use std::sync::Arc;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::{isolated, without_artboards};
use super::export::{isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
};
@@ -161,6 +166,8 @@ struct Row {
folder: String,
/// The encoder options: the format's settings, then the row's own.
options: Value,
/// What makes two rows the same file: format, size, suffix and options.
key: String,
}
impl Row {
@@ -169,8 +176,8 @@ impl Row {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz or pdf"));
if matches!(format.id, "template" | "txt" | "dxf" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
// left over from a raster row switched to SVG or PDF).
@@ -203,13 +210,21 @@ impl Row {
let options = Value::Object(options);
// A PDF preset (built-in or saved) becomes its settings.
let options = if format.id == "pdf" { super::pdf::expand_preset(s, C, &options)?.into_owned() } else { options };
Ok(Self { format, size, suffix, folder, options })
// Two rows that would write the same bytes under the same name are one file.
let key = format!("{}|{}|{suffix}|{options}", format.id, size.label());
Ok(Self { format, size, suffix, folder, options, key })
}
/// Formats that hold a whole document to edit (native, Illustrator, EPS): written from a
/// document of the item alone, one artboard, rather than with an `artboard` option.
fn whole_document(&self) -> bool {
matches!(self.format.id, "vectorcraft" | "ai" | "eps")
}
/// The encoder options for `region`, `artboard` of the document written.
fn options_for(&self, region: Rect, artboard: Option<usize>) -> Result<Value> {
let mut o = self.options.clone();
if let Some(b) = artboard {
if let Some(b) = artboard.filter(|_| !self.whole_document()) {
o["artboard"] = json!(b);
}
if self.format.raster {
@@ -247,6 +262,60 @@ struct ScreenParams {
prefix: Option<String>,
/// Asset ids (Asset Export) to write instead of artboards.
assets: Option<Vec<u64>>,
/// Layer ids to write instead of artboards: each layer's art alone.
layers: Option<Vec<u64>>,
/// The selection instead of artboards: `one` file of it all, or one for `each` object.
selection: Option<String>,
/// The file name pattern ([`naming`]); without it, `prefix` + name + suffix.
name: Option<String>,
/// The counter's first value (`####` in `name`).
counter_start: Option<u64>,
/// The counter counts `item`s (every file of an artboard has its number) or `file`s.
counter_per: Option<String>,
/// The date the pattern's date tokens use (`YYYY-MM-DD`); default today (UTC).
date: Option<String>,
/// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as
/// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`.
folders: Option<String>,
}
/// How exported files are sorted into folders.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Folders {
None,
Rows,
Format,
Item,
ItemFormat,
}
impl Folders {
fn of(o: &ScreenParams) -> Result<Self> {
Ok(match o.folders.as_deref() {
None if o.subfolders || o.preset.is_some() => Self::Rows,
None | Some("none" | "") => Self::None,
Some("rows") => Self::Rows,
Some("format") => Self::Format,
Some("item") => Self::Item,
Some("itemFormat") => Self::ItemFormat,
Some(f) => return Err(bad(C, format!("folders `{f}`: none, rows, format, item or itemFormat"))),
})
}
}
/// One thing a batch export writes a file of per row: an artboard, an asset, a layer, the
/// selection or one selected object, or the whole document.
struct Item {
/// Its name as the user gave it (`<name>`).
name: String,
/// Its name made safe and unique among the items (the default file name, the item folder).
stem: String,
/// The document written: the whole document with `artboard`, or the item alone.
doc: Arc<Document>,
artboard: Option<usize>,
region: Rect,
/// Full Document: what a PDF row writes (every artboard of the document).
every_artboard: Option<Arc<Document>>,
}
impl ScreenParams {
@@ -289,8 +358,9 @@ pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
/// The file names (and bytes) an export wrote, or the files it returns.
#[derive(Default)]
struct Output {
/// Lower-case relative names already used (two identical rows write one file).
taken: HashSet<String>,
/// Lower-case relative names already used, with what wrote each (two identical rows write
/// one file; different files that would share a name get `-2`, `-3`…).
taken: std::collections::HashMap<String, String>,
/// Sub-folders already made.
made: HashSet<String>,
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
@@ -300,9 +370,30 @@ struct Output {
}
impl Output {
/// Is `name` new (not written by an identical row already)?
fn claim(&mut self, name: &str) -> bool {
self.taken.insert(name.to_lowercase())
/// The name the file `key` writes as `name` takes: `name`, else (with `number`) `name-2`,
/// `name-3`… when another file has it; `None` when the same file (`key`) was written as `name`
/// already, or, without `number`, when any file was (the first row wins, as Export for Screens
/// always did when no name pattern is given).
fn claim(&mut self, name: &str, key: &str, number: bool) -> Option<String> {
let (base, ext) = name.rsplit_once('.').map_or((name, ""), |(b, e)| (b, e));
for n in 1.. {
let candidate = if n == 1 {
name.to_string()
} else if ext.is_empty() {
format!("{base}-{n}")
} else {
format!("{base}-{n}.{ext}")
};
match self.taken.get(&candidate.to_lowercase()) {
Some(k) if k == key || !number => return None,
Some(_) => continue,
None => {
self.taken.insert(candidate.to_lowercase(), key.to_string());
return Some(candidate);
}
}
}
None
}
/// Write `bytes` as `name` (relative) under `folder`, or keep them.
@@ -324,32 +415,166 @@ impl Output {
}
}
/// File → Export for Screens: [`export`], with the params remembered in the document
/// (`document.exportSettings`).
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
/// the params remembered in the document (`document.exportSettings`).
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
let r = export(s, p)?;
remember(s, p);
let p = with_preset(s, p)?;
let r = export(s, &p)?;
remember(s, &p);
Ok(r)
}
/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or
/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or
/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get
/// `-2`, `-3`… instead of overwriting.
// ---------- export presets ----------
/// A saved batch export: what to write and how to name and sort it, to use again
/// (`document.exportPresets.*`, `exportPreset`). Kept with the preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScreenExportPreset {
pub name: String,
/// The [`PRESET_KEYS`] of `document.exportForScreens` params.
pub settings: Map<String, Value>,
}
/// What a preset keeps: the formats, their settings, the naming and the folders; not which art
/// (it changes with the document) nor where to (unless given).
pub const PRESET_KEYS: [&str; 13] = [
"formats",
"settings",
"preset",
"name",
"prefix",
"counterStart",
"counterPer",
"folders",
"subfolders",
"includeBleed",
"antiAlias",
"folder",
"fullDocument",
];
/// The built-in export presets: (name, settings).
pub fn builtin_presets() -> Vec<ScreenExportPreset> {
let marks = json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true});
let preset = |name: &str, settings: Value| ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() };
vec![
preset(
"Logo Package",
json!({
"name": "<filename>-<name>",
"folders": "format",
"formats": [
{"format": "svg"},
{"format": "png", "scale": "1x", "suffix": ""},
{"format": "png", "scale": "2x", "suffix": "@2x"},
{"format": "pdf"},
{"format": "pdf", "suffix": "-print", "marks": marks},
],
}),
),
preset("Print-Ready PDF", json!({"formats": [{"format": "pdf", "suffix": "-print", "marks": marks, "bleed": {"useDocument": true}}]})),
]
}
/// The preset called `name`: a saved one, else a built-in one.
fn find_preset(s: &Session, name: &str) -> Option<ScreenExportPreset> {
s.prefs.screen_export_presets.iter().find(|p| p.name == name).cloned().or_else(|| builtin_presets().into_iter().find(|p| p.name == name))
}
/// `p` over the settings of its `exportPreset`, when it names one (its own keys win).
fn with_preset(s: &Session, p: &Value) -> Result<Value> {
let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) };
let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?;
let mut merged = preset.settings;
if let Some(o) = p.as_object() {
merged.extend(o.iter().filter(|(k, _)| k.as_str() != "exportPreset").map(|(k, v)| (k.clone(), v.clone())));
}
Ok(Value::Object(merged))
}
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
let row = |p: &ScreenExportPreset, built_in: bool| json!({"name": p.name, "builtIn": built_in, "settings": p.settings});
let rows: Vec<Value> =
builtin_presets().iter().map(|p| row(p, true)).chain(s.prefs.screen_export_presets.iter().map(|p| row(p, false))).collect();
Ok(json!({ "presets": rows }))
}
pub(super) fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
const SAVE: &str = "document.exportPresets.save";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(SAVE, "give the preset a name"))?.to_string();
if builtin_presets().iter().any(|b| b.name == name) {
return Err(bad(SAVE, format!("`{name}` is a built-in preset: save under another name")));
}
let given = p.get("settings").and_then(Value::as_object).ok_or_else(|| bad(SAVE, "settings is an object of document.exportForScreens params"))?;
let settings: Map<String, Value> = given.iter().filter(|(k, _)| PRESET_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
// Refuse what an export would refuse, now rather than when the preset is used.
check_settings(s, &Value::Object(settings.clone())).map_err(|e| bad(SAVE, e.to_string()))?;
Folders::of(&ScreenParams::of(&Value::Object(settings.clone()))?).map_err(|e| bad(SAVE, e.to_string()))?;
let preset = ScreenExportPreset { name: name.clone(), settings };
let created = match s.prefs.screen_export_presets.iter_mut().find(|q| q.name == name) {
Some(slot) => {
*slot = preset.clone();
false
}
None => {
s.prefs.screen_export_presets.push(preset.clone());
true
}
};
Ok(json!({ "name": name, "created": created, "settings": preset.settings }))
}
pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
const DELETE: &str = "document.exportPresets.delete";
let name = str_param(p, "name").ok_or_else(|| bad(DELETE, "missing `name`"))?;
if builtin_presets().iter().any(|b| b.name == name) {
return Err(bad(DELETE, format!("`{name}` is built in and stays")));
}
let before = s.prefs.screen_export_presets.len();
s.prefs.screen_export_presets.retain(|q| q.name != name);
if s.prefs.screen_export_presets.len() == before {
return Err(bad(DELETE, format!("no saved export preset `{name}`")));
}
Ok(json!({ "deleted": name }))
}
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files the `name` pattern gives one name.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
if o.full_document && o.assets.is_some() {
return Err(bad(C, "give assets or fullDocument, not both"));
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
if sources > 1 {
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
}
let subfolders = o.subfolders || o.preset.is_some();
let folders = Folders::of(&o)?;
// A prefix names files, not folders.
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
let when = match o.date.as_deref() {
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
};
let per_file = match o.counter_per.as_deref() {
None | Some("item") => false,
Some("file") => true,
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
};
let counter_start = o.counter_start.unwrap_or(1);
let selection = match o.selection.as_deref() {
None => None,
Some("one") => Some(false),
Some("each") => Some(true),
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
};
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
if let Some(dir) = folder {
create_dir(dir)?;
}
let doc = s.doc()?.doc.clone();
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// Include Bleed: each artboard grown by the document's bleed.
let doc = if o.include_bleed && doc.setup.has_bleed() {
let mut d = (*doc).clone();
@@ -360,79 +585,66 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
} else {
doc
};
let name_of = |r: &Row, stem: &str| {
let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]);
if subfolders { format!("{}/{file}", r.folder) } else { file }
};
let crop = rows.iter().any(|r| r.format.id != "pdf");
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
let mut out = Output::default();
// Appearance of Black › Printing / Exporting for RGB raster rows.
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
if o.full_document {
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
let mut whole: Option<Document> = None;
// A whole-document copy of an artboard, for the formats that write documents to edit.
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
let mut files = 0u64;
for (index, item) in items.iter().enumerate() {
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue;
}
let bytes = if r.format.id == "pdf" {
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
Some(pattern) => {
let parts = NameParts {
filename: &filename,
name: &item.name,
index: index + 1,
counter,
format: ext,
scale: &if r.format.raster { r.size.label() } else { String::new() },
suffix: &r.suffix,
when,
};
let named = naming::render(pattern, &parts);
let named = if named.is_empty() { item.stem.clone() } else { named };
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
}
None => format!("{prefix}{}{}", item.stem, r.suffix),
};
let file = format!("{stem}.{ext}");
let sub = match folders {
Folders::None => None,
Folders::Rows => Some(r.folder.clone()),
Folders::Format => Some(ext.to_string()),
Folders::Item => Some(item.stem.clone()),
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
};
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
// One page per artboard.
let mut options = r.options.clone();
options["range"] = json!("all");
write(&doc, "pdf", &options)?
write(all, "pdf", &options)?
} else if r.whole_document() && item.artboard.is_some() {
let b = item.artboard.unwrap_or(0);
let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone();
write(&d, r.format.id, &r.options_for(item.region, None)?)?
} else {
if whole.is_none() {
let bounds =
vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
whole = Some(single_artboard(&doc, bounds, "Document"));
}
let Some(d) = whole.as_ref() else { continue };
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
write(d, r.format.id, &r.options_for(region, Some(0))?)?
write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)?
};
out.put(folder, o.zip, name, bytes)?;
}
} else if let Some(ids) = &o.assets {
let assets =
ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::<Result<Vec<_>>>()?;
if assets.is_empty() {
return Err(bad(C, "no assets to export"));
}
for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) {
let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name)
.ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue;
}
let bytes = write(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
} else {
let n = doc.artboards.len();
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect);
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue; // the same file from two identical rows
}
let bytes = write(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
out.put(folder, o.zip, name, bytes)?;
}
files += 1;
}
}
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()));
let zip_name = format!("{prefix}{filename}.zip");
return match folder {
Some(dir) => {
let path = format!("{dir}/{zip_name}");
@@ -450,6 +662,98 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
/// selection (one item, or one per object), else the chosen artboards.
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
// The art of `ids` alone, as an item named `name`.
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
};
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
pieces
.into_iter()
.zip(stems)
.map(|((name, doc, artboard, region), stem)| Item { name, stem, doc, artboard, region, every_artboard: None })
.collect()
};
if o.full_document {
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
// row writes every artboard of the document itself).
let (whole, region) = if crop {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
(Arc::new(single_artboard(doc, bounds, "Document")), bounds)
} else {
(doc.clone(), Rect::ZERO)
};
let name = filename.to_string();
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
}
if let Some(ids) = &o.assets {
let mut pieces = vec![];
for id in ids {
let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?;
let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
pieces.push((a.name.clone(), d, Some(0), r));
}
if pieces.is_empty() {
return Err(bad(C, "no assets to export"));
}
return Ok(named(pieces, "Asset"));
}
if let Some(ids) = &o.layers {
let mut pieces = vec![];
for id in ids {
let layer = doc.node(NodeId(*id)).filter(|n| n.is_layer()).ok_or_else(|| bad(C, format!("no layer {id} (see layer.list)")))?;
let mut art = vec![];
crate::cmd::assets::art_under(doc, layer.id, &mut art);
let name = layer.display_name();
let (d, r) = alone(&art, &name).ok_or_else(|| bad(C, format!("layer `{name}` has no art to export")))?;
pieces.push((name, d, Some(0), r));
}
if pieces.is_empty() {
return Err(bad(C, "no layers to export"));
}
return Ok(named(pieces, "Layer"));
}
if let Some(each) = selection {
if selected.is_empty() {
return Err(bad(C, "select something to export"));
}
let mut pieces = vec![];
if each {
for id in selected {
let name = doc.node(*id).map(|n| n.display_name()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Selection".into());
if let Some((d, r)) = alone(&[*id], &name) {
pieces.push((name, d, Some(0), r));
}
}
} else if let Some((d, r)) = alone(selected, "Selection") {
pieces.push(("Selection".to_string(), d, Some(0), r));
}
if pieces.is_empty() {
return Err(bad(C, "the selection has no art to export"));
}
return Ok(named(pieces, "Selection"));
}
let n = doc.artboards.len();
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = artboard_file_names(doc, &boards);
Ok(boards
.into_iter()
.zip(stems)
.map(|(b, stem)| {
let a = doc.artboards.get(b);
let name = a.map(|a| a.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| stem.clone());
Item { name, stem, doc: doc.clone(), artboard: Some(b), region: a.map_or(Rect::ZERO, |a| a.rect), every_artboard: None }
})
.collect())
}
/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and
/// `assets`, a choice of art), so the dialog reopens on them.
fn remember(s: &mut Session, p: &Value) {
+3 -1
View File
@@ -378,7 +378,9 @@ fn export_for_screens_one_page_per_pdf_unique_names() {
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
assert_eq!(r["files"][0]["name"], "[email protected]");
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
// A native row: each artboard as a document of its own to edit.
let r = s.execute("document.exportForScreens", &json!({"range": "1", "formats": [{"format": "vectorcraft"}]})).unwrap();
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".vectorcraft"), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
@@ -374,3 +374,43 @@ fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
}
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
/// open.
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
let st = s.doc().unwrap();
let (mut rows, mut open) = (vec![], vec![]);
st.doc.walk(|n| {
if n.children().is_some() {
rows.push(n.name.clone().unwrap_or_default());
if st.layers_open.contains(n.id) {
open.push(n.name.clone().unwrap_or_default());
}
}
});
(rows, open)
}
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
/// open as before (with their top-level layers open unless the file says otherwise).
#[test]
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
let mut s = Session::new();
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
let (rows, open) = open_rows(&s);
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
assert_eq!(open, Vec::<String>::new(), "{r}");
}
// The same art as an EPS: its top-level layers open.
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
}
+205
View File
@@ -0,0 +1,205 @@
//! Batch exports through Export for Screens (#1935): a file name pattern (the document's and the
//! item's names, a counter, the date), folders by format, by item or both, layers and the selection
//! as what is exported, and document formats (VectorCraft, Illustrator, EPS) as rows.
use serde_json::{Value, json};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first.
fn session(artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap();
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
s
}
fn export(s: &mut Session, p: Value) -> Value {
s.execute("document.exportForScreens", &p).unwrap()
}
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect()
}
fn names(r: &Value) -> Vec<String> {
files(r).into_iter().map(|(n, _)| n).collect()
}
/// The request in #1935: SVG, PNG, PDF and PDF with printer's marks for artboards 1–3, named
/// `COMPANY-Logo-<filename>-####-YYYYMMDD`, sorted into a folder per extension.
#[test]
fn the_logo_package_of_the_request() {
let mut s = session(4);
let r = export(
&mut s,
json!({
"range": "1-3",
"name": "COMPANY-Logo-<filename>-####-YYYYMMDD.ext",
"date": "2026-10-16",
"folders": "format",
"formats": [
{"format": "svg"},
{"format": "png", "scale": "2x", "suffix": ""},
{"format": "pdf"},
{"format": "pdf", "suffix": "-print", "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}
]
}),
);
let n = names(&r);
assert_eq!(n.len(), 12, "{n:?}");
for (i, number) in ["0001", "0002", "0003"].iter().enumerate() {
let stem = format!("COMPANY-Logo-Untitled-1-{number}-20261016");
for want in [format!("svg/{stem}.svg"), format!("png/{stem}.png"), format!("pdf/{stem}.pdf"), format!("pdf/{stem}-print.pdf")] {
assert!(n.contains(&want), "artboard {}: {want} in {n:?}", i + 1);
}
}
// The printed PDF has room for its marks: a bigger page than the plain one.
let f = files(&r);
let page = |name: &str| {
let bytes = &f.iter().find(|(n, _)| n == name).unwrap().1;
let d = vectorcraft_pdf::import(bytes).unwrap();
let a = d.artboards.first().unwrap().rect;
(a.width(), a.height())
};
let plain = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016.pdf");
let print = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016-print.pdf");
assert!(print.0 > plain.0 && print.1 > plain.1, "{plain:?} vs {print:?}");
}
#[test]
fn folders_by_item_and_by_item_and_format() {
let mut s = session(2);
let rows = json!([{"format": "png"}, {"format": "svg"}]);
let by_item = names(&export(&mut s, json!({"folders": "item", "formats": rows})));
assert_eq!(by_item, ["Artboard-1/Artboard-1.png", "Artboard-1/Artboard-1.svg", "Artboard-2/Artboard-2.png", "Artboard-2/Artboard-2.svg"]);
let both = names(&export(&mut s, json!({"folders": "itemFormat", "formats": rows})));
assert_eq!(both[0], "Artboard-1/png/Artboard-1.png");
let flat = names(&export(&mut s, json!({"folders": "none", "subfolders": true, "formats": rows})));
assert_eq!(flat[0], "Artboard-1.png", "folders wins over subfolders");
// Without folders, subfolders keeps its meaning (a folder per row).
let rows_mode = names(&export(&mut s, json!({"subfolders": true, "formats": rows})));
assert_eq!(rows_mode[0], "1x/Artboard-1.png");
}
#[test]
fn the_counter_counts_items_or_files_from_any_start() {
let mut s = session(2);
let rows = json!([{"format": "png"}, {"format": "svg"}]);
let items = names(&export(&mut s, json!({"name": "logo-###", "counterStart": 9, "formats": rows})));
assert_eq!(items, ["logo-009.png", "logo-009.svg", "logo-010.png", "logo-010.svg"]);
let per_file = names(&export(&mut s, json!({"name": "logo-#", "counterPer": "file", "formats": rows})));
assert_eq!(per_file, ["logo-1.png", "logo-2.svg", "logo-3.png", "logo-4.svg"]);
// A pattern that names different files alike numbers them instead of dropping one.
let same = names(&export(&mut s, json!({"name": "logo", "formats": [{"format": "png"}]})));
assert_eq!(same, ["logo.png", "logo-2.png"]);
// The item's name, its place and the row's size and suffix.
let parts = names(&export(
&mut s,
json!({"name": "<index>_<name>_<scale><suffix>_<format>", "range": "2", "formats": [{"format": "png", "scale": "2x"}]}),
));
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
}
#[test]
fn layers_and_the_selection_are_exported_by_name() {
let mut s = session(1);
let first_layer = s.doc().unwrap().doc.layers[0].id.0;
let top = s.execute("layer.new", &json!({"name": "Dark Mode"})).unwrap()["id"].as_u64().unwrap();
let circle = s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
let r = export(&mut s, json!({"layers": [first_layer, top], "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["Layer-1.svg", "Dark-Mode.svg"]);
// The selection: one file of it all, or a file per object.
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
s.execute("select.set", &json!({"ids": [square, circle]})).unwrap();
assert_eq!(names(&export(&mut s, json!({"selection": "one", "formats": [{"format": "png"}]}))), ["Selection.png"]);
let each = names(&export(&mut s, json!({"selection": "each", "formats": [{"format": "png"}]})));
assert_eq!(each.len(), 2, "{each:?}");
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected");
}
/// Document formats write each artboard as a document of its own: one artboard, its art.
#[test]
fn document_formats_write_one_artboard_each() {
let mut s = session(2);
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]}));
let n = names(&r);
assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]);
let native = &files(&r)[0].1;
let mut t = Session::new();
t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap();
assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone");
}
#[test]
fn bad_batch_params_are_refused() {
let mut s = session(1);
for bad in [
json!({"folders": "byColour"}),
json!({"counterPer": "page"}),
json!({"date": "16/10/2026"}),
json!({"selection": "some"}),
json!({"selection": "one", "fullDocument": true}),
json!({"layers": [99999]}),
json!({"layers": [1], "assets": [1]}),
] {
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
}
}
/// Export presets: the built-in ones, saving (only what a preset keeps), using one under other
/// params, replacing, deleting, and keeping them with the preferences.
#[test]
fn export_presets_save_use_and_delete() {
let mut s = session(2);
let list = s.execute("document.exportPresets.list", &json!({})).unwrap();
let built_in: Vec<&str> =
list["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == true).map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(built_in, ["Logo Package", "Print-Ready PDF"]);
// The Logo Package: SVG, PNG 1x and 2x, PDF and PDF with marks, a folder per format.
let r = export(&mut s, json!({"exportPreset": "Logo Package", "range": "1"}));
assert_eq!(
names(&r),
[
"svg/Untitled-1-Artboard 1.svg",
"png/Untitled-1-Artboard 1.png",
"png/Untitled-1-Artboard [email protected]",
"pdf/Untitled-1-Artboard 1.pdf",
"pdf/Untitled-1-Artboard 1-print.pdf"
]
);
let settings = json!({"name": "ACME-<name>-##", "folders": "item", "formats": [{"format": "jpg", "quality": 80}], "range": "2", "assets": [1]});
let saved = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": settings})).unwrap();
assert_eq!(saved["created"], true);
assert!(saved["settings"].get("range").is_none() && saved["settings"].get("assets").is_none(), "which art is not kept: {saved}");
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2"}));
assert_eq!(names(&r), ["Artboard-2/ACME-Artboard 2-01.jpg"]);
// The call's own params win over the preset's.
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2", "folders": "none"}));
assert_eq!(names(&r), ["ACME-Artboard 2-01.jpg"]);
// Saving again replaces it; the preferences keep it.
let again = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": {"formats": [{"format": "png"}]}})).unwrap();
assert_eq!(again["created"], false);
let prefs = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(prefs["screenExportPresets"][0]["name"], "Web JPG");
s.execute("document.exportPresets.delete", &json!({"name": "Web JPG"})).unwrap();
assert!(s.execute("document.exportForScreens", &json!({"exportPreset": "Web JPG"})).is_err());
// Built-in names stay; bad settings are refused when saved.
for bad in [
("document.exportPresets.save", json!({"name": "Logo Package", "settings": {}})),
("document.exportPresets.save", json!({"name": "", "settings": {}})),
("document.exportPresets.save", json!({"name": "X", "settings": {"formats": [{"format": "txt"}]}})),
("document.exportPresets.save", json!({"name": "X", "settings": {"folders": "sideways"}})),
("document.exportPresets.save", json!({"name": "X"})),
("document.exportPresets.delete", json!({"name": "Logo Package"})),
("document.exportPresets.delete", json!({"name": "Never saved"})),
] {
assert!(s.execute(bad.0, &bad.1).is_err(), "{bad:?}");
}
}
+3 -1
View File
@@ -171,7 +171,9 @@ fn bad_eps_options_are_refused_and_formats_list_eps() {
let e = s.execute("document.exportEps", &p).unwrap_err().to_string();
assert!(e.contains("EPS"), "{p}: {e}");
}
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).is_err());
// Export for Screens writes an EPS per artboard (a document of that artboard alone).
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).unwrap();
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".eps"), "{r}");
let r = s.execute("document.formats", &json!({})).unwrap();
let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone();
// Readable too since EPS import (tests_epsimport.rs).
@@ -139,6 +139,27 @@ fn exports_write_ai_as_save_as_does() {
assert!(b64(&r).starts_with(b"%PDF"));
}
/// A `.ai` with an artboard larger than 14,400 pt has its PDF page written with a `UserUnit`,
/// and reopens at its size from its editing data and from its PDF part.
#[test]
fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20000, "height": 10000})).unwrap();
s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 5000, "height": 3000})).unwrap();
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
assert!(String::from_utf8_lossy(&ai).contains("/UserUnit 1.389"), "20,000 / 14,400 rounded up to the next thousandth");
let size = |s: &Session| {
let r = s.doc().unwrap().doc.artboards[0].rect;
(r.width().round(), r.height().round())
};
assert_eq!(open(&mut s, "big.ai", &ai)["restored"], true);
assert_eq!(size(&s), (20_000.0, 10_000.0));
// Picking its page opens the PDF part.
let r = s.execute("document.open", &json!({"name": "big.ai", "dataBase64": vectorcraft_format::base64_encode(&ai), "pages": "1"})).unwrap();
assert_eq!(r["restored"], false, "{r}");
assert_eq!(size(&s), (20_000.0, 10_000.0));
}
#[test]
fn ait_templates_open_their_document_untitled() {
let mut s = rich();
@@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone()
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
}
#[test]
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
let mut s = session(40.0, 30.0, 1);
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
let on = png(&mut s, json!({"format": "png"}));
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
assert!(png_chunk(&off, b"sRGB").is_none());
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
let r = s.execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
}
#[test]
fn formats_list_the_raster_options() {
let mut s = Session::new();
@@ -217,6 +232,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
}
}
#[test]
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
// While another thread keeps exporting a document whose image can't be decoded (two exports at
// once, or the canvas drawing such a document), PNG exports of art with no image report
// nothing, and every export on the other thread still reports its own image.
use std::sync::atomic::{AtomicBool, Ordering};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im = ImageObject {
key: "broken".into(),
width: 2,
height: 1,
xf: vectorcraft_geom::Affine::scale(20.0),
link: None,
placement: Default::default(),
};
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let mut s = session(400.0, 400.0, 1);
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
let stop = AtomicBool::new(false);
let (tx, rx) = std::sync::mpsc::channel();
// Nothing in the scope panics, so the other thread is always told to stop.
let (theirs, ours) = std::thread::scope(|scope| {
let other = scope.spawn(|| {
let mut warned = vec![];
while !stop.load(Ordering::Relaxed) {
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
warned.push(w.is_ok_and(|w| says_so(&w)));
let _ = tx.send(());
}
warned
});
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
let ours: Vec<Option<bool>> = (0..5)
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
.collect();
stop.store(true, Ordering::Relaxed);
(other.join().ok(), started.then_some(ours))
});
let theirs = theirs.expect("the other thread's exports");
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
}
#[test]
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
@@ -233,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() {
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
}
#[test]
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
let mut s = session(200.0, 400.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let boards = s.doc().unwrap().doc.artboards.len();
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
let img = rgba(&b64(&r));
assert_eq!((img.width(), img.height()), (40, 300));
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
// Twice the resolution, and a window that holds both art and empty page.
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
assert_eq!((img.width(), img.height()), (80, 20));
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
}
#[test]
fn export_rect_refuses_what_cannot_be_exported() {
let mut s = session(200.0, 400.0, 1);
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let bad = [
json!({"format": "png", "rect": [0, 0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, -5, 10]}),
json!({"format": "png", "rect": [0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, "a", 10]}),
json!({"format": "png", "rect": "20,40,40,300"}),
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
];
for p in bad {
assert!(s.execute("document.export", &p).is_err(), "{p}");
}
}
@@ -91,3 +91,20 @@ fn a_chosen_artboard_s_svg_leaves_out_its_neighbours_art_in_a_shared_group() {
assert_eq!(third_only.matches("<path").count() + third_only.matches("<rect").count(), 1, "{third_only}");
assert!(third_only.contains(" C") || third_only.contains("<ellipse"), "{third_only}");
}
/// An object off a chosen artboard whose drop shadow reaches onto it is in that artboard's SVG.
#[test]
fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let x0 = s.doc().unwrap().doc.artboards[0].rect.x0;
let id = s.execute("shape.rectangle", &json!({"x": x0 - 30.0, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
let paths = |s: &mut Session| {
let svg = s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap();
let text = svg["text"].as_str().unwrap().to_string();
text.matches("<path").count() + text.matches("<rect").count()
};
assert_eq!(paths(&mut s), 0, "the plain rectangle is off the artboard");
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
}
+42 -21
View File
@@ -114,9 +114,12 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
fonts
}
/// The fonts (family, style, version) of the type of `d` that prints on pages showing `areas`
/// (document space): visible type on `layers` (the layers that print) but not on a template layer
/// (of area type and type on a path that don't fit, the runs that their layout draws),
/// The type of `d` that prints on pages showing `areas` (document space): the fonts (family, style,
/// version) it is set in, and the ids of the area type and type on a path among the objects of the
/// document whose text doesn't fit (not those in the art of symbols, opacity masks, patterns,
/// brushes or placed documents).
/// The type that prints is visible type on `layers` (the layers that print) but not on a template
/// layer (of area type and type on a path that don't fit, the runs that their layout draws),
/// where what it paints ([`vectorcraft_render::painted_bounds`], its effects applied) meets an area
/// inside the clipping paths around it, the type in the opacity masks and patterns of the objects
/// that print and in the pieces their brushes draw, and the type of the symbols that their
@@ -125,27 +128,32 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
/// fills or strokes of its own, are taken with it when what it paints meets an area, and so is the
/// art of a placed document (`d` as [`Document::with_placed_art`] gives it, for that art's
/// resources).
pub(super) fn printed_fonts(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> BTreeSet<UsedFont> {
let mut walk = Printed { d, fonts: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> (BTreeSet<UsedFont>, BTreeSet<NodeId>) {
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
for l in layers {
walk.node(l, Some(areas));
}
walk.fonts
(walk.fonts, walk.overflow)
}
/// The walk of [`printed_fonts`].
/// The walk of [`printed_type`].
struct Printed<'a> {
d: &'a Document,
/// Whether the walk is among the objects of the document, not in art drawn for one.
own: bool,
fonts: BTreeSet<UsedFont>,
/// The objects of the document whose text doesn't fit.
overflow: BTreeSet<NodeId>,
/// The symbols and patterns whose art was walked.
symbols: BTreeSet<String>,
patterns: BTreeSet<String>,
}
impl Printed<'_> {
/// Adds the fonts of `n` and its descendants that print: those whose bounds meet one of `areas`,
/// or with `None` (in the art of a symbol, opacity mask, pattern or placed document, in brush
/// pieces, or in a live object) all of them but those outside the clipping path of a clip group.
/// Adds the fonts of `n` and its descendants that print, and the ids of those that don't fit
/// while [`Printed::own`] is set: those whose bounds meet one of `areas`, or with `None` (in the
/// art of a symbol, opacity mask, pattern or placed document, in brush pieces, or in a live
/// object) all of them but those outside the clipping path of a clip group.
fn node(&mut self, n: &Node, areas: Option<&[Rect]>) {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return;
@@ -162,9 +170,9 @@ impl Printed<'_> {
}
// The art that its opacity mask, pattern paints and brushed strokes draw.
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
let runs = match &n.kind {
let (runs, overflows) = match &n.kind {
NodeKind::Text(t) => drawn_runs(t),
_ => vec![],
_ => (vec![], false),
};
let items: Vec<&AppearanceItem> =
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
@@ -180,7 +188,7 @@ impl Printed<'_> {
let brushed = vectorcraft_brush::has_brush(n);
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
if let Some(m) = mask {
self.node(&m.art, None);
self.art(&m.art);
}
let d = self.d;
for name in patterns {
@@ -188,19 +196,22 @@ impl Printed<'_> {
&& let Some(p) = d.pattern(name)
{
for art in &p.art {
self.node(art, None);
self.art(art);
}
}
}
// The pieces its brushes draw along the path (art and pattern brushes leave type out).
if brushed {
for piece in vectorcraft_brush::node_pieces(d, n).unwrap_or_default() {
self.node(&piece, None);
self.art(&piece);
}
}
}
match &n.kind {
NodeKind::Text(_) => {
if overflows && self.own {
self.overflow.insert(n.id);
}
for r in runs {
self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()));
// Inline graphics draw their symbols' art.
@@ -212,7 +223,7 @@ impl Printed<'_> {
NodeKind::SymbolInstance { symbol, .. } => self.symbol(symbol),
NodeKind::PlacedDocument(p) => {
for art in p.art(n) {
self.node(&art, None);
self.art(&art);
}
}
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } if !moved => {
@@ -240,10 +251,19 @@ impl Printed<'_> {
if self.symbols.insert(name.to_string())
&& let Some(s) = d.symbols.iter().find(|s| s.name == name)
{
self.node(&s.art, None);
self.art(&s.art);
}
}
/// Adds the fonts of `art`, drawn for an object of the document but not one of its objects (the
/// art of a symbol, opacity mask, pattern or placed document, or a brush piece), with
/// [`Printed::node`]'s `None`.
fn art(&mut self, art: &Node) {
let own = std::mem::replace(&mut self.own, false);
self.node(art, None);
self.own = own;
}
/// Whether what `n` paints, its effects applied, meets one of `areas`.
fn meets(&self, n: &Node, areas: &[Rect]) -> bool {
vectorcraft_render::painted_bounds(self.d, n).is_some_and(|b| areas.iter().any(|r| b.intersect(*r).area() > 0.0 || r.contains_rect(b)))
@@ -256,14 +276,15 @@ impl Printed<'_> {
}
}
/// The runs of `t` that print: all of them, or of area type and type on a path that don't fit, the
/// runs that their layout draws glyphs or inline graphics of.
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> Vec<&TextRun> {
/// The runs of `t` that print, and whether its text doesn't fit: all of them, or of area type and
/// type on a path that don't fit, the runs that their layout draws glyphs or inline graphics of.
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) {
let drawn: Option<BTreeSet<usize>> = (!matches!(t.kind, TextKind::Point))
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
.filter(|lay| lay.overflow)
.map(|lay| lay.glyphs.iter().map(|g| g.run).chain(lay.inlines.iter().map(|i| i.run)).collect());
t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect()
let runs = t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect();
(runs, drawn.is_some())
}
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
+69 -11
View File
@@ -82,20 +82,42 @@ impl Cell {
}
}
/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside).
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut parts = vec![];
let mut quoted = false;
let mut start = 0;
for (i, ch) in s.char_indices() {
if ch == '"' {
quoted = !quoted;
} else if ch == sep && !quoted {
parts.push(s.get(start..i).unwrap_or_default());
start = i + ch.len_utf8();
}
}
parts.push(s.get(start..).unwrap_or_default());
parts
}
/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed.
fn cell(c: &str) -> Cell {
let c = c.trim();
match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) {
Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true },
None => Cell { text: c.trim_matches('"').to_string(), quoted: false },
}
}
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
let rows: Vec<Vec<Cell>> = csv
.lines()
// Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes.
let rows: Vec<Vec<Cell>> = split_unquoted(csv, '\n')
.into_iter()
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.map(|l| {
let sep = if l.contains('\t') { '\t' } else { ',' };
l.split(sep)
.map(|c| {
let c = c.trim();
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
Cell { text: c.trim_matches('"').to_string(), quoted }
})
.collect()
let tabs = split_unquoted(l, '\t');
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
parts.into_iter().map(cell).collect()
})
.collect();
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
@@ -192,7 +214,9 @@ fn drop_empty_rows(g: &mut GraphSpec) {
fn fmt_value(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
// Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so
// `{:.0}` prints its exact digits.
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
} else {
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
}
@@ -1900,6 +1924,16 @@ mod tests {
use crate::{NodeId, Session};
#[test]
fn fmt_value_does_not_saturate_beyond_i64() {
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
assert_eq!(super::fmt_value(42.0), "42");
assert_eq!(super::fmt_value(-0.0), "0");
assert_eq!(super::fmt_value(2.5), "2.5");
}
fn graph(s: &mut Session, ty: &str) -> NodeId {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
NodeId(
@@ -2358,6 +2392,30 @@ mod tests {
assert_eq!(cells, g.cells(), "{csv}");
}
#[test]
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
let r = s
.execute(
"graph.create",
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
)
.unwrap();
let id = r["id"].as_u64().unwrap();
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(before["series"], series);
assert_eq!(before["categories"], categories);
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
+37 -1
View File
@@ -5,7 +5,8 @@
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
//! `vectorcraft_doc::intertwine`).
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
//!
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
/// Replace Intertwine group `id` by a plain group of the art it draws
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
super::live::fix_ids(d, &mut g);
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
Ok(())
}
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
/// many. A live Intertwine is expanded before it can be ungrouped:
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
let mut groups = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if is_intertwine(c) {
groups.push(c.id);
}
});
}
}
for g in groups.iter().rev() {
expand_group(d, *g)?;
}
Ok(groups.len())
}
// ---------- Arrange ----------
#[derive(Clone, Copy, PartialEq)]
+44 -10
View File
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Artwork Bounds",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)",
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
has_doc,
artboard_fit_art
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Selected Art",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)}",
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
has_selection,
artboard_fit_sel
),
@@ -412,6 +412,9 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
if !st.doc.is_editable(id) {
return Err(bad(C, "the object is hidden or locked"));
}
if !in_isolation(&st.doc, st.isolation, id) {
return Err(bad(C, "the object is outside the isolated group"));
}
let layer = n.is_layer();
let rows = st.layer_rows.clone();
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
@@ -645,6 +648,11 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": lid.0 }))
}
/// Whether `id` lies inside the isolated group or layer (always, outside isolation mode).
fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
isolation.is_none_or(|iso| iso != id && doc.ancestry(id).is_some_and(|a| a.contains(&iso)))
}
/// The selection column: select a row's art.
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.selectAll";
@@ -652,7 +660,7 @@ fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
let add = bool_or(p, "add", false);
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
let ids: Vec<NodeId> = if !st.doc.is_editable(id) || !in_isolation(&st.doc, st.isolation, id) {
vec![]
} else if n.is_layer() {
n.layer_art(true)
@@ -826,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
@@ -890,17 +898,43 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
xf.is_finite().then_some(xf)
}
/// The artboard fits the art that exports draw: hidden objects and layers, guides and template
/// layers don't count.
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
/// template layers don't count. An artboard with no art on it stays as it is, and the command
/// fails when none has art. The fits are one undo step named Artboard Options.
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
let d = &s.doc()?.doc;
let indices =
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
let mut fits = vec![];
for i in indices {
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
fits.push((i, was, Rect::new(b.x0, b.y0, b.x0 + b.width().max(1.0), b.y0 + b.height().max(1.0))));
}
}
if fits.is_empty() {
return Err(EngineError::Other("no artwork".into()));
}
s.edit("Artboard Options", |d, _| {
for &(i, was, now) in &fits {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = now;
let id = a.id;
// Moved and not resized, it takes its guides along, as in Artboard Options.
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
d.move_artboard_guides(id, now.origin() - was.origin());
}
}
Ok(())
})?;
ok()
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let st = s.doc()?;
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+13 -2
View File
@@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
if ids.len() < 2 {
return Err(bad(C, "highlight two or more rows to merge"));
}
let into =
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
// The last highlighted layer that is not inside another highlighted row: with a layer and its
// sublayer both highlighted (Select All in the panel), the sublayer travels with its layer
// instead of becoming the target (#2383).
let roots: HashSet<NodeId> = row_roots(d, &ids).into_iter().collect();
let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer);
let into = ids
.iter()
.rev()
.copied()
.filter(is_layer)
.find(|id| roots.contains(id))
.or_else(|| ids.iter().rev().copied().find(is_layer))
.ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
let around = d.ancestry(into).unwrap_or_default();
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
+1 -1
View File
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
}
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
fn fix_ids(d: &mut Document, n: &mut Node) {
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
if n.id == NodeId(0) {
n.id = d.alloc_id();
}
+12 -3
View File
@@ -664,9 +664,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
}
// Print rasters run to gigabytes decoded: the same allowance opening them has.
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
let reader = || {
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))
};
// The image's size as RGBA, read from its header, is held to that allowance before anything
// is decoded (#1717).
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
}
let mut reader = reader()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
reader.limits(limits);
+7
View File
@@ -103,6 +103,11 @@ impl PreviewMode {
}
}
/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0))
}
/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -125,6 +130,8 @@ pub struct DocSettings {
pub bleed: [f64; 4],
pub background: Background,
pub color_mode: ColorMode,
/// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end).
#[serde(deserialize_with = "de_ppi")]
pub raster_effects_ppi: f64,
pub preview_mode: PreviewMode,
}
+35 -20
View File
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, No
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
use super::edit::{duplicate_in, outermost, selected_roots};
use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform};
use super::opacitymask::percent;
use super::*;
use crate::{EngineError, LastTransform};
@@ -252,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), 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)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) evenly, in their order on that line: the first and last objects stay put, and fewer than three objects don't move; with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key object, select.key: the key object stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; not used without a key object or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), 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)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line: without spacing and a key object they are spaced evenly, the first and last objects stay put, and fewer than three objects don't move; with spacing and a key object, see spacing (two objects are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
can_distribute,
distribute
),
@@ -493,14 +493,14 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (outermost(&s.doc()?.doc, v), false),
None => (selected_roots(s)?, bool_or(p, "anchors", false)),
None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)),
};
apply_transform(s, "Transform", ids, m, p, anchors)
}
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.move")?;
let ids = selected_roots(s)?;
let ids = selected_roots_to_transform(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
}
@@ -524,7 +524,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
}
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
}
let ids = selected_roots(s)?;
let ids = selected_roots_to_transform(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true)
}
@@ -534,7 +534,7 @@ fn about(o: Point, a: Affine) -> Affine {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.rotate")?;
let ids = selected_roots(s)?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let mut angle = f64_or(p, "angle", 0.0);
if bool_or(p, "absolute", false) {
@@ -547,7 +547,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["sy"], "object.scale")?;
let ids = selected_roots(s)?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
let sy = f64_or(p, "sy", sx * 100.0) / 100.0;
@@ -558,7 +558,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let m = match p.get("axis") {
Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0),
@@ -578,7 +578,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.shear")?;
let ids = selected_roots(s)?;
let ids = selected_roots_to_transform(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 vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
@@ -602,7 +602,7 @@ fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
};
match last {
LastTransform::Matrix(m, options) => {
let ids = selected_roots(s)?;
let ids = selected_roots_to_transform(s)?;
apply_transform(s, "Transform Again", ids, m, &merge(options), true)
}
LastTransform::Each(params) => super::menucmds::transform_each_again(s, &merge(params)),
@@ -1232,6 +1232,15 @@ fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a
Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?))
}
/// `spacing` of `object.distribute` and `object.distributeSpacing`: a finite number, or `None`
/// when it is missing or null. Any other value is an error.
fn spacing(p: &Value, cmd: &str) -> Result<Option<f64>> {
p.get("spacing")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad(cmd, "`spacing` must be a number")))
.transpose()
}
/// `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()
@@ -1294,8 +1303,8 @@ fn distribute_to(s: &Session, p: &Value, ids: &[NodeId], preview: bool, cmd: &st
}
/// 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.
/// compound path or selected group the key is in. Align to the key object, and Distribute 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?)?;
@@ -1439,6 +1448,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
let spacing = spacing(p, "object.distribute")?;
// Anchor points selected with Direct Selection are distributed themselves, as Align aligns them.
if !s.doc()?.selection.anchors.is_empty() {
return match direction(p, "object.distribute")? {
@@ -1468,8 +1478,14 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
};
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
let n = items.len();
// Two objects are enough on an artboard: they go to its edges.
if n < 2 || (n < 3 && board.is_none()) {
// With a spacing, the key object (or the compound path or selected group it is in) stays where
// it is, and the lines are placed `spacing` apart, outward from its line, in their order.
// Without a key object the spacing is not used. `from_key` is the key's index in `items` and
// the spacing.
let from_key = spacing.zip(key_root(s, &ids)).and_then(|(g, k)| Some((items.iter().position(|(id, _)| *id == k)?, g)));
// Two objects are enough on an artboard, where they go to its edges, and when they are spaced
// from a key object.
if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) {
return ok();
}
let (first_box, last_box) = (&items[0].1, &items[n - 1].1);
@@ -1486,7 +1502,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.enumerate()
.map(|(i, (id, r))| {
let target = first + (last - first) * i as f64 / (n - 1) as f64;
let target = match from_key {
Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64),
None => first + (last - first) * i as f64 / (n - 1) as f64,
};
let delta = target - key(r, horiz);
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
@@ -1499,11 +1518,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
let board = distribute_to(s, p, &ids, preview, "object.distributeSpacing")?;
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 spacing = spacing(p, "object.distributeSpacing")?;
if !s.doc()?.selection.anchors.is_empty() {
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
}
+43 -7
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), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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)",
"{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, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), 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), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
@@ -70,10 +70,21 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
/// An `index` that names no artboard is an error.
fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> {
let t = s.tool_options_of("artboard");
let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize;
(i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))
let i = match p.get("index") {
Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?,
None => t["active"].as_u64().unwrap_or(0) as usize,
};
Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
}
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
/// [`artboard_indices`] (`missing index`, `no such artboard`).
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
@@ -106,7 +117,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
}
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let (i, art) = board_and_art(s, p, "artboard.duplicate")?;
let art = artboard_art(s, i, art)?;
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() };
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
@@ -119,7 +130,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
/// then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
let art = artboard_art(s, i, art)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= 1 {
@@ -210,6 +221,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"spaceBefore",
"spaceAfter",
"hyphenate",
"wordsLongerThan",
"afterFirst",
"beforeLast",
"hyphenLimit",
"hyphenationZone",
"hyphenateCapitalized",
"mojikumi",
"kinsoku",
"direction",
@@ -346,6 +363,25 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
let letters = |k: &str, lo: f64, hi: f64| num(k).map(|v| v.round().clamp(lo, hi) as u32);
if let Some(v) = letters("wordsLongerThan", 1.0, 25.0) {
para.hyphenation.words_longer_than = v;
}
if let Some(v) = letters("afterFirst", 1.0, 15.0) {
para.hyphenation.after_first = v;
}
if let Some(v) = letters("beforeLast", 1.0, 15.0) {
para.hyphenation.before_last = v;
}
if let Some(v) = letters("hyphenLimit", 0.0, 25.0) {
para.hyphenation.hyphen_limit = v;
}
if let Some(v) = num("hyphenationZone") {
para.hyphenation.zone = v.clamp(0.0, MAX_PARA);
}
if let Some(v) = flag("hyphenateCapitalized") {
para.hyphenation.capitalized = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
+12 -10
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Preview",
[],
None,
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, options not applied yet…), settings} without printing",
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
has_doc,
preview
),
@@ -92,7 +92,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
let set = settings(C, doc, p)?;
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
pv.warnings.extend(fonts_warning(doc, set.print_layers, &areas(&pv)));
pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv)));
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
v["settings"] = to_json(&set)?;
Ok(v)
@@ -118,14 +118,16 @@ fn on_page(area: Rect, to_page: Affine, (w, h): (f64, f64)) -> Option<Rect> {
(r.area() > 0.0).then_some(r)
}
/// The warning about type in `doc` that prints in the fallback font: on the layers `which` prints,
/// on pages that print `areas`.
fn fonts_warning(doc: &Document, which: PrintLayers, areas: &[Rect]) -> Option<String> {
/// The warnings about the type in `doc` that prints (on the layers `which` prints, on pages that
/// print `areas`): type in the fallback font, and type that doesn't fit its frame.
fn type_warnings(doc: &Document, which: PrintLayers, areas: &[Rect]) -> Vec<String> {
// Placed documents print their files' art, which names the resources this adds.
let doc = doc.with_placed_art();
let mut layers = doc.layers.clone();
vectorcraft_pdf::keep_layers(&mut layers, which);
super::fonts::substitution_warning_of(super::fonts::printed_fonts(&doc, &layers, areas))
let (fonts, overflow) = super::fonts::printed_type(&doc, &layers, areas);
let overflow: Vec<_> = overflow.into_iter().collect();
super::fonts::substitution_warning_of(fonts).into_iter().chain(super::typecmd::overflow_warning(&overflow)).collect()
}
const PRINT: &str = "file.print";
@@ -162,13 +164,13 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
// Print as Bitmap and the flattener preset (Advanced) render and flatten it first.
let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets, s.prefs.black_output == "rich")?;
let path = str_param(p, "path");
// Fonts are read from the type before raster effects and Print as Bitmap turn it into images.
// The type is read before raster effects and Print as Bitmap turn it into images.
let which = set.print_layers;
if !postscript {
let printed = prepared.as_ref().unwrap_or(doc);
let fonts = fonts_warning(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?));
let typed = type_warnings(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?));
let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
r.warnings.extend(fonts);
r.warnings.extend(typed);
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
}
let level = match p.get("level").filter(|v| !v.is_null()) {
@@ -212,6 +214,6 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let areas: Vec<Rect> = plan.sheets.iter().filter_map(|sh| on_page(sh.area, sh.view * sh.place, sh.size)).collect();
warnings.extend(fonts_warning(&full, which, &areas));
warnings.extend(type_warnings(&full, which, &areas));
write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings }))
}
+11 -1
View File
@@ -170,6 +170,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 {
/// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out
/// of the image, which then only holds what the effects add around it (a shadow to go under the
/// vector object). The document's raster effects settings add room around it and finish its pixels.
/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not
/// positive, or when its PNG can't be encoded.
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
// What it paints (its members' effects included), and how far its fills' and strokes' raster
// effects reach beyond that.
@@ -177,8 +179,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
let look = out.raster_effects.clone();
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
let b = b.inflate(reach, reach);
// An area too large for f64 gives a zero scale and an image transform that is not finite.
let area = b.width() * b.height();
if !area.is_finite() {
return None;
}
// Whole pixels, and no larger than 64 Mpx.
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
// Anti-alias off: hard edges (the effects themselves stay smooth).
let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
+9 -3
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Shaper Face",
[],
None,
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original → {id}",
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original; while a group's originals are isolated, it selects one of that group's originals → {id}",
has_doc,
select
),
@@ -687,10 +687,13 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
}
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
/// The topmost editable Shaper Group at `point`, measured by its source shapes in `construction`.
/// When `within` is given, only that group is considered.
fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeId>) -> Option<NodeId> {
let mut groups = vec![];
d.walk(|n| {
if n.extras().shaper.is_some()
&& within.is_none_or(|g| g == n.id)
&& d.is_editable(n.id)
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
@@ -704,7 +707,10 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?;
let st = s.doc()?;
let source = bool_or(p, "source", false);
let Some(group) = group_at(&st.doc, point, source) else { return Ok(json!({"id": null})) };
// In Construction mode a source pick stays in the group whose source shapes are isolated.
let isolated =
st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id)));
let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) };
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
let target = if source {
sources(node)
+14 -9
View File
@@ -3,7 +3,9 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile};
use vectorcraft_doc::{
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile,
};
use super::appearance::{ItemTarget, edit_items, item_target};
use super::paint::painted;
@@ -18,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)",
has_doc,
stroke_set
),
@@ -82,6 +84,8 @@ pub(crate) struct StrokeChange {
dash: Option<Option<Vec<f64>>>,
dash_offset: Option<f64>,
align_dashes: Option<bool>,
/// Align Stroke: centred, inside or outside the shape (or the glyphs, on type).
align: Option<StrokeAlign>,
}
/// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent.
@@ -116,6 +120,7 @@ impl StrokeChange {
dash,
dash_offset: num("dashOffset"),
align_dashes: p.get("alignDashes").and_then(Value::as_bool),
align: named(&ALIGNS, p, "align", cmd)?,
})
}
@@ -127,12 +132,16 @@ impl StrokeChange {
&& self.dash.is_none()
&& self.dash_offset.is_none()
&& self.align_dashes.is_none()
&& self.align.is_none()
}
pub(crate) fn apply(&self, st: &mut StrokeLayer) {
if let Some(w) = self.weight {
st.width = w;
}
if let Some(a) = self.align {
st.align = a;
}
if let Some(c) = self.cap {
st.cap = c;
}
@@ -189,7 +198,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
let align = named(&ALIGNS, p, "align", C)?;
let arrow_align = match str_param(p, "arrowAlign") {
None => None,
Some("extend") => Some(ArrowAlign::Extend),
@@ -202,9 +210,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
};
let set = |st: &mut StrokeLayer| {
change.apply(st);
if let Some(a) = align {
st.align = a;
}
if let Some(a) = sa {
st.start_arrow = a;
}
@@ -270,8 +275,8 @@ impl Session {
impl DocState {
/// Which Stroke panel values the selected objects' strokes (type: every run's) don't share,
/// and whether Align Stroke applies to them. One walk of the selection, so callers that ask
/// every frame cache it by revision.
/// and whether Align Stroke applies to them (no open path among them). One walk of the
/// selection, so callers that ask every frame cache it by revision.
pub fn stroke_mixed(&self) -> StrokeMixed {
let mut nodes = vec![];
for id in &self.selection.objects {
@@ -284,7 +289,7 @@ impl DocState {
for n in &nodes {
match &n.kind {
NodeKind::Text(t) => {
can_align = false;
// The characters' stroke aligns to the glyph outlines, which are closed.
strokes.extend(t.runs.iter().map(|r| r.style.stroke_layer()));
}
_ => {
+48 -1
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\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} 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}",
"{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?, align?, 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), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} 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
),
@@ -85,6 +85,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| type_step(s, &json!({"attribute": "size", "by": -f64_or(p, "by", 1.0)}))
),
cmd!(
"type.size.increaseByFive",
"Increase Font Size by Five Steps",
[],
Some("Cmd+Alt+Shift+."),
"{} type.step {attribute: size, by: 5}: five times the Size/Leading increment (typeSizeIncrement)",
has_selection,
|s, _| type_step(s, &json!({"attribute": "size", "by": 5.0}))
),
cmd!(
"type.size.decreaseByFive",
"Decrease Font Size by Five Steps",
[],
Some("Cmd+Alt+Shift+,"),
"{} type.step {attribute: size, by: -5}: five times the Size/Leading increment (typeSizeIncrement)",
has_selection,
|s, _| type_step(s, &json!({"attribute": "size", "by": -5.0}))
),
cmd!(
"type.resetTracking",
"Reset Tracking and Kerning",
@@ -94,6 +112,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
reset_tracking
),
cmd!(
"type.resetHorizontalScale",
"Reset Horizontal Scale",
[],
Some("Cmd+Shift+X"),
"{ids?: [id…]} set the horizontal scale to 100% (as the Character panel shows it, the object's own transform included) in all the text of the type objects in `ids`, else in the text the Type tool has selected (nothing at a caret), else in every selected type object, as one undo step → {ids}",
has_selection,
reset_horizontal_scale
),
cmd!(
"text.discardEmpty",
"Discard Empty Type",
@@ -734,6 +761,26 @@ fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
}
/// Cmd/Ctrl+Shift+X: horizontal scale 100% (the Character panel's value, which includes the
/// object's own transform) in the type objects in `ids`, else in the Type tool's selected text,
/// else in every selected type object. At a caret the command leaves the text as it is.
fn reset_horizontal_scale(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.resetHorizontalScale";
let ranges = type_ranges(s, p, C, |_, _| None)?;
if !ranges.is_empty() {
s.edit("Reset Horizontal Scale", |d, _| {
for &(id, a, b, _) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
let h_scale = super::typecmd::local_type_value(t, 100.0, (1.0, 10000.0), true, C)?;
edit::style_range(&mut t.runs, a, b, |st| st.h_scale = h_scale);
refresh_bounds(t);
}
Ok(())
})?;
}
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
}
/// Tracking (1/1000 em) that makes the first paragraph of `t` exactly `target` wide, if it fits
/// on one line at all.
fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
+59
View File
@@ -269,6 +269,23 @@ pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> V
result
}
/// The warning that Print gives for `ids`, area type and type on a path whose text doesn't fit
/// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names
/// the first 20 ids and counts the rest; `None` when `ids` is empty.
pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option<String> {
const NAMED: usize = 20;
let (named, rest) = ids.split_at(ids.len().min(NAMED));
let mut list = named.iter().map(|i| i.0.to_string()).collect::<Vec<_>>().join(", ");
if !rest.is_empty() {
list = format!("{list} and {} more", rest.len());
}
match ids {
[] => None,
[_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {list})")),
_ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame doesn't print (ids {list})", ids.len())),
}
}
/// Text objects among `ids` and their descendants.
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
@@ -871,6 +888,48 @@ mod area_tests {
assert_eq!(old.burasagari, Burasagari::None);
}
/// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation
/// options, rounded and clamped, in one undo step; they are saved only when not the defaults,
/// and paragraph styles carry them.
#[test]
fn hyphenation_options_are_set_per_paragraph_and_saved_only_when_not_default() {
use vectorcraft_doc::HyphenationOptions;
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": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
_ => panic!("text"),
};
assert_eq!(paras(&s).0.hyphenation, HyphenationOptions::default());
assert!(serde_json::to_value(paras(&s).0).unwrap().get("hyphenation").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute(
"text.setFormat",
&json!({"wordsLongerThan": 7.6, "afterFirst": 0, "beforeLast": 40, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, "start": 5, "end": 5}),
)
.unwrap();
let want = HyphenationOptions { words_longer_than: 8, after_first: 1, before_last: 15, hyphen_limit: 2, zone: 18.0, capitalized: false };
let (first, second) = paras(&s);
assert_eq!((first.hyphenation, &second.hyphenation), (HyphenationOptions::default(), &want), "only the paragraph the caret is in");
assert_eq!(serde_json::to_value(&second).unwrap()["hyphenation"]["hyphen_limit"], 2);
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap();
assert_eq!(back.hyphenation, want);
s.execute("text.setFormat", &json!({"hyphenLimit": -3, "hyphenationZone": 5000})).unwrap();
assert_eq!((paras(&s).0.hyphenation.hyphen_limit, paras(&s).1.hyphenation.zone), (0, 1296.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paras(&s).1.hyphenation, HyphenationOptions::default());
// A paragraph style carries them; an older file's paragraph reads the defaults.
s.execute("paraStyle.new", &json!({"name": "Loose", "attrs": {"hyphenation": {"words_longer_than": 9}}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Loose", "id": id})).unwrap();
assert_eq!(paras(&s).0.hyphenation, HyphenationOptions { words_longer_than: 9, ..HyphenationOptions::default() });
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap();
assert_eq!(old.hyphenation, HyphenationOptions::default());
}
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
/// carry them; params override; the values are journaled; imported text keeps the Roman
+32 -9
View File
@@ -665,16 +665,39 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
if total == 0 {
return Ok(json!({ "count": 0 }));
}
edit_runs::<()>(s, "Replace", &ids, |text, _| {
let mut out = String::with_capacity(text.len());
let mut last = 0;
for (a, b) in find_all(text, &find, mc, ww) {
out.push_str(&text[last..a]);
out.push_str(&rep);
last = b;
super::overprint::edit_counted(s, "Replace", |d| {
let mut changed = 0;
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
// Matches as (run, start, end) with offsets inside the run. Inline graphics are art, not
// characters to rewrite.
let mut hits = vec![];
let mut base = 0;
for (ri, r) in t.runs.iter().enumerate() {
if r.inline.is_none() {
for (a, b) in find_all(&r.text, &find, mc, ww) {
if r.text.get(a..b) != Some(rep.as_str()) {
hits.push((ri, base + a, base + b, a, b));
}
}
}
base += r.text.len();
}
if hits.is_empty() {
continue;
}
// Last match first, so the offsets of earlier ones stay valid. The paragraph styles follow
// each edit, as `text.editRange` does.
for (ri, ta, tb, a, b) in hits.into_iter().rev() {
t.splice_paras(ta, tb, &rep);
if let Some(r) = t.runs.get_mut(ri) {
r.text.replace_range(a..b, &rep);
}
}
changed += 1;
refresh_bounds(t);
}
out.push_str(&text[last..]);
out
Ok(changed)
})?;
Ok(json!({ "count": total }))
}
+38 -10
View File
@@ -430,7 +430,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
const C: &str = "dataset.select";
let (applied, skipped) = s.edit("Apply Data Set", |d, sel| {
let (applied, skipped, mut typed) = s.edit("Apply Data Set", |d, sel| {
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
// One object may be driven by several variables; walk them in definition order, so two
// text variables on the same object land the later one's value and both are counted.
@@ -462,6 +462,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
});
let mut applied = 0usize;
let mut skipped = 0usize;
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
let mut typed = Vec::new();
for (id, var) in pairs {
// A binding whose variable is gone, whose value this row doesn't carry, or whose
// value doesn't fit the variable's kind, is left alone and counted, never
@@ -483,7 +485,10 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
let done = match (kind, &value) {
// The Type tool's own whole-range replace, so a dataset's text is styled and
// laid out exactly as typing it would be.
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok(),
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok_and(|()| {
typed.push(id);
true
}),
(VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| {
n.visible = *show;
true
@@ -502,9 +507,12 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
d.variables.active_dataset = Some(name.to_string());
// Every selected object left hidden or locked, such as one the row hid, is deselected.
sel.deselect_uneditable(d);
Ok((applied, skipped))
Ok((applied, skipped, typed))
})?;
Ok(json!({"name": name, "applied": applied, "skipped": skipped}))
// Two text variables on one object rewrite it twice; it is named once.
typed.sort_unstable();
typed.dedup();
Ok(super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped})))
}
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
@@ -529,11 +537,14 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells,
/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as
/// [`csv_escape`] writes them. Blank lines are skipped.
/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes
/// hold as written, spaces included, and drops white space after the closing quote at the end
/// of the cell. Blank lines are skipped.
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new());
let (mut in_quotes, mut line) = (false, 1usize);
// `closed`: where the closing quote of a quoted cell falls in `cur`.
let (mut in_quotes, mut closed, mut line) = (false, None, 1usize);
let mut chars = text.chars().peekable();
loop {
let c = chars.next();
@@ -544,13 +555,14 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
chars.next();
} else {
in_quotes = false;
closed = Some(cur.len());
}
}
Some('"') if cur.is_empty() => in_quotes = true,
Some(',') if !in_quotes => row.push(std::mem::take(&mut cur)),
Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
Some('\r') if !in_quotes => {}
Some('\n') | None if !in_quotes => {
row.push(std::mem::take(&mut cur));
row.push(end_csv_cell(&mut cur, &mut closed));
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push(std::mem::take(&mut row));
// +1 for the header.
@@ -576,6 +588,20 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
}
}
/// Take the cell read so far out of `cur`. Without a closing quote (`closed` is `None`) the cell
/// is trimmed; with one, the text before it is kept as written and the text after it is
/// trimmed at the end.
fn end_csv_cell(cur: &mut String, closed: &mut Option<usize>) -> String {
let cell = std::mem::take(cur);
match closed.take() {
Some(at) => match cell.split_at_checked(at) {
Some((quoted, after)) => format!("{quoted}{}", after.trim_end()),
None => cell,
},
None => cell.trim().to_string(),
}
}
fn csv_escape(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
}
@@ -590,7 +616,9 @@ fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result<bool> {
}
fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result<Option<DataValue>> {
let t = cell.trim();
// A text cell is not trimmed here (the parser trims unquoted cells); image, graph and
// visibility cells are trimmed.
let t = if kind == VariableKind::Text { cell } else { cell.trim() };
if t.is_empty() {
return Ok(None);
}
@@ -892,7 +920,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Data Set",
["Window", "Variables"],
None,
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped}",
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits)}",
has_doc,
dataset_select
),
+74 -7
View File
@@ -300,6 +300,9 @@ pub struct DocState {
/// What Select → Reselect repeats: the last selection command, its params and the objects
/// selected when it ran (a Same command's reference objects).
pub last_selection_cmd: Option<(String, Value, Vec<NodeId>)>,
/// The saved selection last recalled (`select.recall`), which Select → Update Selection
/// changes. It is not saved with the file, and a recall is not an undo step.
pub recalled_selection: RecalledSelection,
/// Process-unique id of this open document (tab indices shift when tabs close).
pub uid: u64,
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
@@ -342,7 +345,7 @@ static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
/// its top-level layers open.
/// its top-level layers open (a `.ai` another app wrote opens with none open).
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
@@ -387,6 +390,57 @@ impl OpenRows {
}
}
/// The saved selection last recalled, which Select → Update Selection changes. The Select commands
/// give each saved selection an id for the session and keep the ids for every version of the
/// document's saved selections they make or read, so the recalled one is found again after Edit
/// Selection…, Undo and Redo. A version that nothing holds any more (neither the document nor its
/// undo history) is dropped the next time a version is added.
#[derive(Clone, Debug, Default)]
pub struct RecalledSelection {
/// The recalled saved selection's id.
id: Option<u64>,
/// Each version of the saved selections (identified by its allocation), with the id of each saved
/// selection in it.
versions: Vec<(std::sync::Weak<Vec<vectorcraft_doc::SavedSelection>>, Vec<u64>)>,
/// The last id given.
last: u64,
}
impl RecalledSelection {
/// The ids of `list`'s saved selections; a version not met before gets new ones.
pub(crate) fn ids(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>) -> Vec<u64> {
let version = vectorcraft_doc::Shared::downgrade(list);
if let Some((_, ids)) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version)) {
return ids.clone();
}
let ids: Vec<u64> = (0..list.len()).map(|_| self.new_id()).collect();
self.remember(list, ids.clone());
ids
}
/// An id no saved selection has had.
pub(crate) fn new_id(&mut self) -> u64 {
self.last += 1;
self.last
}
/// Keep `ids` as those of `list`'s saved selections, dropping versions nothing holds any more.
pub(crate) fn remember(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>, ids: Vec<u64>) {
let version = vectorcraft_doc::Shared::downgrade(list);
self.versions.retain(|(v, _)| v.strong_count() > 0 && !v.ptr_eq(&version));
self.versions.push((version, ids));
}
/// Recall the saved selection at `index` in `list`.
pub(crate) fn recall(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>, index: usize) {
self.id = self.ids(list).get(index).copied();
}
/// Where the recalled saved selection is in `list`, if `list` holds it.
pub fn index_in(&self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>) -> Option<usize> {
let id = self.id?;
let version = vectorcraft_doc::Shared::downgrade(list);
let (_, ids) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version))?;
ids.iter().position(|&x| x == id).filter(|&i| i < list.len())
}
}
impl DocState {
pub fn new(mut doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
@@ -411,6 +465,7 @@ impl DocState {
undo_group: None,
last_transform: None,
last_selection_cmd: None,
recalled_selection: RecalledSelection::default(),
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
mask_view: None,
transparency_grid: false,
@@ -563,16 +618,17 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [
"select.allOnArtboard",
];
/// The commands that resize the active artboard, which the app passes as their `index`: Fit to
/// Artwork Bounds and Fit to Selected Art (Object › Artboards). A `command.batch` given `artboard`
/// passes it on, as `index`, to each of their steps that names none.
/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the
/// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to
/// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to
/// each of their steps that names none.
pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"];
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves
/// them out ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 13] = [
const REPLAY_ONLY: [(&str, &str); 14] = [
("file.new", "created"),
("document.save", "modified"),
("file.saveAs", "modified"),
@@ -583,6 +639,7 @@ const REPLAY_ONLY: [(&str, &str); 13] = [
("object.distribute", "artboard"),
("object.distributeSpacing", "artboard"),
("artboard.fitToArt", "index"),
("artboard.fitToArt", "indices"),
("artboard.fitToSelection", "index"),
("select.allOnArtboard", "artboard"),
("command.batch", "artboard"),
@@ -847,6 +904,10 @@ pub struct Prefs {
/// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pdf_presets: Vec<vectorcraft_pdf::PdfPreset>,
/// File → Export for Screens: the user's batch export presets (`document.exportPresets.*`;
/// the built-in ones aren't stored). A local library, not a Preferences dialog field.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub screen_export_presets: Vec<cmd::fileio::ScreenExportPreset>,
// File Handling (continued)
/// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`).
pub templates_folder: String,
@@ -946,7 +1007,7 @@ impl Default for Prefs {
font_preview_size: s("medium"),
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: true,
highlight_alternate_glyphs: false,
placeholder_text: true,
units_general: s("points"),
units_stroke: s("points"),
@@ -1032,6 +1093,7 @@ impl Default for Prefs {
new_doc_presets: vec![],
recent_new_docs: vec![],
pdf_presets: vec![],
screen_export_presets: vec![],
templates_folder: String::new(),
append_converted: true,
use_compression: false,
@@ -1136,6 +1198,8 @@ pub struct Session {
pub(crate) plane_widget_press: bool,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
/// Which selected type overflows its frame, for the markers every tool but the Type tools shows.
pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks,
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
/// starts; dropping it stops it.
pub(crate) font_search: Option<cmd::fontfiles::FontSearch>,
@@ -1206,6 +1270,7 @@ impl Session {
liquify_stroke: None,
plane_widget_press: false,
ruler_guide: None,
overflow_marks: Default::default(),
font_search: None,
link_search: None,
search_rules: None,
@@ -1959,6 +2024,8 @@ mod tests_objexpand;
#[cfg(test)]
mod tests_opacitymask;
#[cfg(test)]
mod tests_outlineobject;
#[cfg(test)]
mod tests_outlinestroke;
#[cfg(test)]
mod tests_overprint;
+81
View File
@@ -113,6 +113,25 @@ fn a_selected_group_that_contains_the_key_object_stays_in_place() {
}
}
/// With a spacing, Distribute moves a compound path or selected group that contains the key object
/// as one object and keeps it in place: the other object's left (top) edge goes that far from its
/// left (top) edge.
#[test]
fn distribute_with_a_spacing_keeps_what_contains_the_key_object_in_place() {
for (make, add) in [(&["object.compoundPath.make"][..], false), (&["object.group"], true), (&["object.group", "object.group"], true)] {
let (mut s, key, outer, r) = key_inside(make);
if add {
s.execute("select.add", &json!({"ids": [outer.0]})).unwrap();
}
assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}");
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 5, "bounds": "geometric"})).unwrap();
assert_eq!(starts(&s, &[outer, r], false), [0.0, 5.0], "{make:?}: 5 pt right of it, it stays");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.distribute", &json!({"vertical": "top", "spacing": 5, "bounds": "geometric"})).unwrap();
assert_eq!(starts(&s, &[outer, r], true), [90.0, 85.0], "{make:?}: 5 pt above it, it stays");
}
}
#[test]
fn arrange_order() {
let mut s = session();
@@ -452,6 +471,7 @@ fn align_and_distribute_reject_bad_params_without_changing_state() {
("object.distribute", json!({"vertical": "centre"}), v),
("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v),
("object.distribute", json!({"horizontal": "left", "to": "page"}), to),
("object.distribute", json!({"horizontal": "left", "spacing": "5"}), "`spacing` must be a number"),
("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"),
("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"),
("object.distributeSpacing", json!({"axis": "vertical", "to": 0}), to),
@@ -599,6 +619,39 @@ fn distribute_spacing_to_artboard_spans_it_with_equal_gaps() {
}
}
/// With a spacing and a key object, each Distribute button keeps the key object (20, 60 and 40 pt
/// wide or tall, the key in the middle) in place and places the left edges, centers or right edges
/// (top edges, centers or bottom edges) that far apart, outward from the key's, in their order on
/// that line. A spacing of 0 puts them all on the key's line, and two objects are enough.
/// Without a key object the spacing is not used, and with one it is used with Align to Artboard too.
#[test]
fn distribute_with_a_spacing_spaces_the_objects_from_the_key_object() {
for vertical in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let ids = selected_row(&mut s, &[(100.0, 20.0), (150.0, 60.0), (260.0, 40.0)], vertical);
let (axis, lines) = if vertical { ("vertical", ["top", "center", "bottom"]) } else { ("horizontal", ["left", "center", "right"]) };
s.execute("object.distribute", &json!({axis: lines[0], "spacing": 10})).unwrap();
assert_eq!(starts(&s, &ids, vertical), [100.0, 180.0, 260.0], "{axis}: no key object, evenly between the outermost");
s.execute("edit.undo", &json!({})).unwrap();
s.select(|_, sel| sel.key = Some(ids[1])).unwrap();
for (line, at) in lines.into_iter().zip([[140.0, 150.0, 160.0], [160.0, 150.0, 170.0], [180.0, 150.0, 180.0]]) {
for to in [json!("key"), json!("artboard"), json!(null)] {
s.execute("object.distribute", &json!({axis: line, "spacing": 10, "to": to})).unwrap();
assert_eq!(starts(&s, &ids, vertical), at, "{axis} {line} to {to}: 10 pt from the key");
s.execute("edit.undo", &json!({})).unwrap();
}
}
s.execute("object.distribute", &json!({axis: lines[0], "spacing": 0, "to": "key"})).unwrap();
assert_eq!(starts(&s, &ids, vertical), [150.0; 3], "{axis}: on the key's line");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [ids[0].0, ids[2].0]})).unwrap();
s.select(|_, sel| sel.key = Some(ids[2])).unwrap();
s.execute("object.distribute", &json!({axis: lines[0], "spacing": 30, "to": "key"})).unwrap();
assert_eq!(starts(&s, &[ids[0], ids[2]], vertical), [230.0, 260.0], "{axis}: two objects");
}
}
#[test]
fn align_both_centers() {
let mut s = session();
@@ -1888,6 +1941,34 @@ fn isolating_an_object_that_doesnt_exist_says_so() {
assert!(!e.to_string().contains("type"), "{e}");
}
/// A ring (a compound path with a hole) and its isolated paths.
fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let outer = rect(s, 0.0, 0.0, 40.0, 40.0);
let hole = rect(s, 10.0, 10.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [outer.0, hole.0]})).unwrap();
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("object.isolate", &json!({"id": c.0})).unwrap();
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
(c, outer, hole)
}
/// Isolating a compound path makes its paths objects of their own: the hole of a ring, selected
/// alone, moves without the ring around it. Outside isolation it still moves its compound path.
#[test]
fn an_isolated_compound_paths_member_moves_alone() {
let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let mut s = session();
let (_, outer, hole) = isolated_ring(&mut s);
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0]})).unwrap();
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 40.0), "only the hole moved");
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 45.0), "Move: only the hole");
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 50.0), "not isolated: the whole compound path moved");
}
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
/// than it has reports `childCount`, and invalid values are rejected, not reinterpreted.
#[test]
+27
View File
@@ -505,6 +505,33 @@ fn delete_duplicate_and_replace_symbols() {
assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err());
}
#[test]
fn symbol_export_type_defaults_to_movie_clip_and_round_trips() {
let mut s = session();
let (_, name, _) = make_symbol(&mut s);
let list = s.execute("symbol.list", &json!({})).unwrap();
assert_eq!(list["symbols"][0]["exportType"], "movieClip");
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).is_none());
s.execute("symbol.options", &json!({"name": name, "exportType": "graphic"})).unwrap();
assert_eq!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["exportType"], "graphic");
assert_eq!(s.doc().unwrap().doc.unknown[vectorcraft_doc::SYMBOL_EXPORT_TYPE][&name], json!("graphic"));
let copy = s.execute("symbol.duplicate", &json!({"name": name})).unwrap();
assert_eq!(copy["exportType"], "graphic");
s.execute("symbol.options", &json!({"name": name, "newName": "Renamed", "exportType": "movieClip"})).unwrap();
let list = s.execute("symbol.list", &json!({})).unwrap();
let renamed = list["symbols"].as_array().unwrap().iter().find(|x| x["name"] == "Renamed").unwrap();
assert_eq!(renamed["exportType"], "movieClip");
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).and_then(|m| m.get("Renamed")).is_none());
assert_eq!(s.doc().unwrap().doc.unknown[vectorcraft_doc::SYMBOL_EXPORT_TYPE]["Box copy"], json!("graphic"));
s.execute("symbol.delete", &json!({"name": "Box copy"})).unwrap();
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).is_none());
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let r = s.execute("symbol.new", &json!({"name": "Icon", "exportType": "graphic"})).unwrap();
assert_eq!(r["exportType"], "graphic");
assert!(s.execute("symbol.new", &json!({"exportType": "bogus"})).is_err());
assert!(s.execute("symbol.options", &json!({"name": "Renamed"})).is_err());
}
#[test]
fn sprayer_command_creates_a_symbol_set_and_alt_removes() {
let mut s = session();
+62 -1
View File
@@ -4,7 +4,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, TextObject};
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, StrokeAlign, TextObject};
use super::*;
@@ -133,3 +133,64 @@ fn character_stroke_options_round_trip_natively_and_old_files_load() {
assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_miter_limit, st.stroke_dash), (LineCap::Butt, LineJoin::Miter, 10.0, None));
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_miter_limit").is_none(), "defaults aren't written");
}
/// Align Stroke on live type (Reddit redd.it/1dwhos6): the characters' stroke goes inside or
/// outside the glyphs without creating outlines, for every run or a range, and is kept by the
/// native format and Create Outlines.
#[test]
fn align_stroke_reaches_the_characters_stroke() {
let mut s = session();
let id = stroked_text(&mut s);
assert!(s.active().unwrap().stroke_mixed().can_align, "type can take an inside or outside stroke");
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 60}));
run(&mut s, "stroke.set", json!({"align": "outside"}));
let t = text(&s, id);
assert!(t.runs.iter().all(|r| r.style.stroke_align == StrokeAlign::Outside), "every run");
assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.items.is_empty(), "no object-level stroke is added");
assert_eq!(s.shown_stroke().unwrap().align, StrokeAlign::Outside, "the Stroke panel shows it");
let m = s.active().unwrap().stroke_mixed();
assert!(!m.align && m.can_align, "{m:?}");
// A range takes its own alignment through strokeOptions; the runs then differ.
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "strokeOptions": {"align": "inside"}}));
let t = text(&s, id);
assert_eq!(t.runs[0].style.stroke_align, StrokeAlign::Inside);
assert_eq!(t.runs.last().unwrap().style.stroke_align, StrokeAlign::Outside);
assert!(s.active().unwrap().stroke_mixed().align, "mixed alignments light no button");
// document.inspect reports it, as for paths.
let doc = run(&mut s, "document.inspect", json!({}));
assert_eq!(doc["layers"][0]["children"][0]["strokeOptions"]["align"], "inside", "{doc}");
// Saved and reopened.
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
let NodeKind::Text(b) = &back.node(id).unwrap().kind else { panic!("not type") };
assert_eq!(b.runs, text(&s, id).runs);
// Create Outlines keeps it on the glyphs.
run(&mut s, "select.set", json!({ "ids": [id.0] }));
let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let mut aligns = vec![];
d.node(NodeId(g)).unwrap().walk(&mut |n: &vectorcraft_doc::Node| aligns.extend(n.appearance.stroke().map(|st| st.align)));
assert!(aligns.contains(&StrokeAlign::Inside) && aligns.contains(&StrokeAlign::Outside), "{aligns:?}");
}
/// Copying type's appearance onto a path keeps its characters' stroke alignment (the Eyedropper's
/// own stroke attributes have none, on paths as on type).
#[test]
fn a_path_copying_types_appearance_takes_its_stroke_alignment() {
let mut s = session();
let id = stroked_text(&mut s);
run(&mut s, "stroke.set", json!({"align": "inside"}));
let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 150, "width": 50, "height": 50}))["id"].as_u64().unwrap();
run(&mut s, "appearance.copyFrom", json!({"source": id.0, "ids": [rect]}));
assert_eq!(s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().align, StrokeAlign::Inside);
}
#[test]
fn a_centred_character_stroke_writes_no_alignment() {
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_align").is_none(), "the default isn't written");
let mut old = serde_json::to_value(CharStyle { stroke_align: StrokeAlign::Outside, ..Default::default() }).unwrap();
assert_eq!(old["stroke_align"], "Outside");
old.as_object_mut().unwrap().remove("stroke_align");
let st: CharStyle = serde_json::from_value(old).unwrap();
assert_eq!(st.stroke_align, StrokeAlign::Center, "files written before it existed stroke centred");
}
+39
View File
@@ -122,6 +122,45 @@ fn type_fills_go_above_or_below_the_characters() {
assert_eq!(node(&s, t).appearance.contents_at(), 1);
}
#[test]
fn an_effect_on_the_types_own_stroke_exports_as_the_offset_outline() {
let mut s = session();
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "paint.setStroke", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 4}, "item": 0}));
let doc = s.doc().unwrap().doc.clone();
let svg = vectorcraft_svg::export(&doc, &Default::default());
assert!(!svg.contains("<text") && svg.contains("<path"), "{svg}");
// The baked type is its outlines, and the stroke's outline lies 4 pt outside the glyphs on every side.
let glyphs = vectorcraft_render::effects::outline_text(&node(&s, t)).unwrap().geometric_bounds().unwrap();
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
let n = baked.node(t).unwrap();
assert!(!matches!(n.kind, NodeKind::Text(_)), "{n:?}");
let b = n.geometric_bounds().unwrap();
let off = [glyphs.x0 - b.x0, glyphs.y0 - b.y0, b.x1 - glyphs.x1, b.y1 - glyphs.y1];
assert!(off.iter().all(|o| (o - 4.0).abs() < 0.5), "{off:?}");
}
#[test]
fn type_with_an_inline_graphic_stays_type_when_its_own_stroke_has_an_effect() {
let mut s = session();
run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 10, "height": 10}));
run(&mut s, "symbol.new", json!({"name": "Dot"}));
run(&mut s, "edit.clear", json!({}));
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
run(&mut s, "text.insertInline", json!({"id": t.0, "at": 0, "symbol": "Dot"}));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "paint.setStroke", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 4}, "item": 0}));
// Exports keep it as type, with its inline graphic.
let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap();
assert!(matches!(baked.node(t).unwrap().kind, NodeKind::Text(_)));
}
#[test]
fn group_appearance_round_trips_and_expands() {
let mut s = session();
+167
View File
@@ -186,6 +186,61 @@ fn paintbrush_uses_stroke_blob_brush_fills() {
assert!(area(&ps[1].1) > 1500.0);
}
/// Merge only with selection: a Blob Brush stroke merges with the selected blobs it touches and
/// leaves the others. Keep selected, off by default: the blob a stroke draws or merges into is not
/// selected afterwards, and the rest of the selection stays; on, it is selected.
#[test]
fn blob_brush_merges_only_with_the_selection_and_keep_selected_off_deselects() {
let mut s = session();
let r = rect(&mut s, 600.0, 400.0, 50.0, 50.0);
let selected = |s: &Session| s.doc().unwrap().selection.objects.clone();
let blob = |s: &mut Session, p: serde_json::Value| NodeId(s.execute("path.blob", &p).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let a = blob(&mut s, json!({"points": [[100, 60], [100, 140]], "size": 20}));
assert_eq!(selected(&s), vec![r], "Keep selected is off by default: the new blob is not selected");
let b = blob(&mut s, json!({"points": [[200, 60], [200, 140]], "size": 20, "keepSelected": true}));
assert_eq!(selected(&s), vec![b], "Keep selected on: the new blob is selected");
let n = paths(&s).len();
let (area_a, area_b) = (area(&path(&s, a)), area(&path(&s, b)));
// Only `b` is selected: the stroke across both merges into `b` alone.
let undo = undo_steps(&s);
let across = json!({"points": [[50, 100], [250, 100]], "size": 10, "mergeOnlySelection": true, "keepSelected": false});
assert_eq!(blob(&mut s, across.clone()), b);
assert_eq!(paths(&s).len(), n);
assert_eq!(area(&path(&s, a)), area_a, "a is left as it was");
assert!(area(&path(&s, b)) > area_b + 500.0, "the stroke joined b");
assert!(selected(&s).is_empty());
assert_eq!(undo_steps(&s), undo + 1);
// With nothing selected, the stroke merges with nothing, and Keep selected off leaves the new
// blob unselected.
let new = blob(&mut s, across);
assert!(new != a && new != b && paths(&s).len() == n + 1, "a new blob");
assert!(selected(&s).is_empty());
// A selection that holds no blob: a new blob, and the selection stays.
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let other = blob(&mut s, json!({"points": [[50, 300], [250, 300]], "size": 10, "mergeOnlySelection": true, "keepSelected": false}));
assert_eq!(paths(&s).len(), n + 2);
assert_eq!(selected(&s), vec![r]);
// Without the options, a stroke merges with every touching blob, selected or not, and the
// blob it merges into is not selected.
let all = blob(&mut s, json!({"points": [[50, 100], [250, 100]], "size": 10}));
assert!([a, b, new].contains(&all) && paths(&s).len() == n, "one blob of a, b and the new one");
assert_eq!(selected(&s), vec![r]);
// Keep selected on: the blob a stroke merges into is selected.
assert_eq!(blob(&mut s, json!({"points": [[150, 80], [150, 120]], "size": 10, "keepSelected": true})), all);
assert_eq!(paths(&s).len(), n);
assert_eq!(selected(&s), vec![all]);
// The tool passes both options; Keep selected is off by default.
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
s.select_tool("blobBrush", view()).unwrap();
assert_eq!(s.tool_options()["keepSelected"], json!(false));
s.set_tool_option("mergeOnlySelection", &json!(true));
drag(&mut s, "blobBrush", &[(150.0, 50.0), (150.0, 320.0)], Mods::default());
assert_eq!(paths(&s).len(), n, "merged into the selected blob only");
assert!(selected(&s).is_empty());
assert!(s.doc().unwrap().doc.node(all).is_some() && s.doc().unwrap().doc.node(other).is_some());
}
/// Three points already curve in the saved path: both ends carry a handle into the curve.
#[test]
fn path_curvature_with_three_points_saves_end_handles() {
@@ -430,6 +485,25 @@ fn anchor_point_tool_converts() {
assert!(d < 0.5, "{q:?}");
}
/// #2267: clicking one handle end with the Anchor Point tool retracts that handle alone, as one
/// undo step.
#[test]
fn anchor_point_tool_click_on_a_handle_retracts_it() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
drag(&mut s, "anchorPoint", &[(100.0, 100.0), (130.0, 80.0)], Mods::default());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let smooth = path(&s, id).subpaths[0].anchors[0];
let undo = undo_steps(&s);
click(&mut s, "anchorPoint", 130.0, 80.0);
let a = path(&s, id).subpaths[0].anchors[0];
assert!(!a.has_out());
assert_eq!((a.p, a.h_in, a.kind), (smooth.p, smooth.h_in, vectorcraft_geom::AnchorKind::Corner));
assert_eq!(undo_steps(&s), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[0], smooth);
}
#[test]
fn scissors_opens_closed_path() {
let mut s = session();
@@ -881,6 +955,99 @@ fn remove_anchor_points_keeps_the_paths_closed() {
assert_eq!(spec.menu, &["Object", "Path"][..]);
}
fn selected_anchors(s: &Session, id: NodeId) -> Vec<(usize, usize)> {
s.doc().unwrap().selection.anchors.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default()
}
fn anchor_points(s: &Session, id: NodeId) -> Vec<Point> {
path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect()
}
#[test]
fn remove_anchor_points_selects_the_next_anchor_so_it_can_go_on_along_the_path() {
// #2241: after Remove Anchor Points the next anchor along is selected, so running it again
// removes that one without picking it first. The path stays selected the whole way.
let mut s = session();
let anchors: Vec<_> = (0..5).map(|i| json!({"x": 100 * i, "y": 100 * (i % 2)})).collect();
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
let original = anchor_points(&s, id);
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
for (remaining, next) in [(4, 2), (3, 3), (2, 4)] {
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0);
assert_eq!(path(&s, id).subpaths[0].anchors.len(), remaining);
assert_eq!(selected_anchors(&s, id), [(0, 1)], "anchor {next} of the original is selected");
assert_eq!(path(&s, id).subpaths[0].anchors[1].p, original[next]);
assert_eq!(s.doc().unwrap().selection.objects, [id]);
}
// One anchor can't make a path: the last run removes it, and nothing is left selected.
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 1);
assert!(s.doc().unwrap().doc.node(id).is_none());
assert!(s.doc().unwrap().selection.is_empty());
}
#[test]
fn remove_anchor_points_selects_the_anchor_before_an_open_end_and_wraps_a_closed_path() {
// An open path's last anchor has nothing after it: the one before is selected.
let mut s = session();
let anchors: Vec<_> = (0..4).map(|i| json!({"x": 100 * i, "y": 0})).collect();
let open = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
let original = anchor_points(&s, open);
s.execute("select.anchors", &json!({"id": open.0, "anchors": [[0, 3]]})).unwrap();
s.execute("path.removeAnchors", &json!({})).unwrap();
assert_eq!(selected_anchors(&s, open), [(0, 2)]);
assert_eq!(anchor_points(&s, open)[2], original[2]);
// A closed path goes round: removing its last anchor selects its first.
let r = rect(&mut s, 0.0, 200.0, 100.0, 80.0);
let corners = anchor_points(&s, r);
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 3]]})).unwrap();
s.execute("path.removeAnchors", &json!({})).unwrap();
assert_eq!(selected_anchors(&s, r), [(0, 0)]);
assert_eq!(anchor_points(&s, r)[0], corners[0]);
}
#[test]
fn remove_anchor_points_selects_one_next_anchor_per_subpath_and_per_path() {
// Several anchors of one subpath: the anchor after the last of them. Each path and each
// subpath gets its own, at its index once a subpath left too short to keep is gone.
let mut s = session();
let made = s.execute("path.create", &json!({"d": "M0 0 L50 0 M0 100 L50 100 L100 100 L150 100 L200 100 L250 100"})).unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
let original = anchor_points(&s, id);
let anchors: Vec<_> = (0..4).map(|i| json!({"x": 100 * i, "y": 400})).collect();
let other = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [1, 1], [1, 2]]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0);
let sp = &path(&s, id).subpaths;
assert_eq!(sp.len(), 1, "the first subpath, down to one anchor, is gone");
assert_eq!(selected_anchors(&s, id), [(0, 1)]);
assert_eq!(sp[0].anchors[1].p, original[5], "the anchor after the two removed ones");
assert_eq!(selected_anchors(&s, other), [(0, 0)]);
// One undo step brings back the anchors and the selection they were removed from.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(anchor_points(&s, id), original);
assert_eq!(selected_anchors(&s, id), [(0, 0), (1, 1), (1, 2)]);
assert_eq!(selected_anchors(&s, other), [(0, 0)]);
}
#[test]
fn remove_anchor_point_tool_and_delete_still_select_the_remainder_whole() {
// Only Remove Anchor Points moves the selection on. The Delete Anchor Point tool leaves the
// path selected whole, and so does the Delete key, so that a second Delete removes the rest.
for command in ["path.removeAnchor", "path.deleteAnchors"] {
let mut s = session();
let anchors: Vec<_> = (0..4).map(|i| json!({"x": 100 * i, "y": 0})).collect();
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
let params = if command == "path.removeAnchor" { json!({"id": id.0, "subpath": 0, "anchor": 1}) } else { json!({}) };
s.execute(command, &params).unwrap();
assert!(selected_anchors(&s, id).is_empty(), "{command}");
assert_eq!(s.doc().unwrap().selection.objects, [id], "{command}");
}
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
+33
View File
@@ -260,6 +260,39 @@ fn export_selection_keeps_a_groups_shadow_in_the_crop() {
assert!(img.get_pixel((155.0 - b[0]) as u32, img.height() / 2)[3] > 0, "the shadow is in the image");
}
/// Export with Use Artboards off and Fit to Artwork Bounds take in the drop shadow of a group and
/// of type.
#[test]
fn the_arts_bounds_take_in_the_shadow_of_a_group_and_of_type() {
for kind in ["group", "type"] {
let mut s = session();
let id = if kind == "group" {
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap();
s.execute("shape.rectangle", &json!({"x": 130, "y": 100, "width": 20, "height": 20})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()
} else {
s.execute("text.create", &json!({"x": 100, "y": 120, "text": "Hill"})).unwrap()["id"].as_u64().unwrap()
};
let plain = s.doc().unwrap().doc.node(NodeId(id)).unwrap().visual_bounds().unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 8, "y": 0, "blur": 0}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let png = |v: &serde_json::Value| {
image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8()
};
let art = s.execute("document.export", &json!({"format": "png", "useArtboards": false, "background": "transparent"})).unwrap();
let sel = s.execute("document.exportSelection", &json!({"format": "png", "background": "transparent"})).unwrap();
let x0 = sel["bounds"][0].as_f64().unwrap();
let img = png(&art);
assert!(png(&sel) == img, "{kind}: the same image as Export Selection's");
let past = (plain.x1 + 1.0 - x0).ceil() as u32;
assert!(img.enumerate_pixels().any(|(x, _, p)| x >= past && p[3] > 0), "{kind}: the shadow is in the image, right of the object");
s.execute("artboard.fitToArt", &json!({})).unwrap();
let board = s.doc().unwrap().doc.artboards[0].rect;
assert!(board.x0 <= plain.x0 && board.x1 >= plain.x1 + 8.0, "{kind}: the artboard holds the shadow 8 pt right of {plain:?}: {board:?}");
}
}
/// The crop of a brushed path is its visual bounds, with no room added for brush art.
#[test]
fn export_selection_crops_a_brushed_path_to_its_visual_bounds() {
+61
View File
@@ -151,6 +151,67 @@ fn release_gives_the_objects_back_as_they_were() {
assert!(red(&render(&s), 50, 30));
}
/// #2540: Object › Expand turns the group into a plain group of what it draws (the rings, then the
/// part of A drawn over B as a clip group) in one undo step, looking the same; Ungroup then takes
/// it apart.
#[test]
fn expand_turns_the_group_into_plain_art_that_ungroups() {
let mut s = session();
let (g, a, b) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
let NodeKind::Group { children, clip: false } = &n.kind else { panic!("a group: {}", n.kind_label()) };
assert_eq!(children.len(), 3, "the rings, then the part of A drawn over B");
assert_eq!((children[0].id, children[1].id), (a, b), "the rings keep their ids and order");
assert!(matches!(children[2].kind, NodeKind::Group { clip: true, .. }));
assert!(children.iter().all(|c| c.extras().intertwine.is_none()), "no area is left over");
let mut ids = vec![];
n.walk(&mut |c| ids.push(c.id));
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left");
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
// Plain art: Ungroup takes it apart.
s.execute("object.ungroup", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(s.doc().unwrap().selection.objects.len(), 3);
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
// Undo brings the Intertwine group back.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. }));
}
/// Expand reaches an Intertwine group inside a selected group, and with every option on also
/// outlines the strokes of the rings and of the part drawn over the other one.
#[test]
fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() {
let mut s = session();
let (g, ..) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap());
select(&mut s, &[g, c]);
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("object.expand", &json!({})).unwrap();
let (mut intertwines, mut strokes) = (0, 0);
s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| {
intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. }));
strokes += usize::from(!n.appearance.stroke_paint().is_none());
});
assert_eq!((intertwines, strokes), (0, 0));
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand");
}
#[test]
fn the_arranged_areas_move_with_the_group() {
let mut s = session();
+10 -6
View File
@@ -130,16 +130,17 @@ fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
/// A batch passes its `artboard` to each Fit to Artwork Bounds and Fit to Selected Art step that
/// names no `index`, as their `index` (past the document's last artboard, the last one), and a
/// step's own `index` is kept. A recorded action leaves out the `index` the app passes them, so
/// the action resizes the artboard active when it plays.
/// step's own `index` is kept. A recorded action leaves out the `index` or `indices` the app
/// passes them, so the action resizes the artboard active when it plays.
#[test]
fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 30})).unwrap();
// Across the three artboards, so it is on each.
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 610, "height": 30})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (was, art) = (boards(&s), vectorcraft_geom::Rect::new(10.0, 10.0, 30.0, 40.0));
let (was, art) = (boards(&s), vectorcraft_geom::Rect::new(10.0, 10.0, 620.0, 40.0));
let step = |command: &str, params: Value| json!({"command": command, "params": params});
s.execute("command.batch", &json!({"artboard": 9, "commands": [step("artboard.fitToArt", json!({}))]})).unwrap();
assert_eq!(boards(&s), [was[0], was[1], art], "artboard 3, the last one, for a batch's artboard past it");
@@ -149,10 +150,13 @@ fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() {
assert_eq!(boards(&s)[0], art, "an index the step names is kept");
let start = s.journal.len();
s.execute("artboard.fitToArt", &json!({"index": 1})).unwrap();
s.execute("artboard.fitToArt", &json!({"indices": [1, 2]})).unwrap();
s.execute("artboard.fitToSelection", &json!({"index": 1})).unwrap();
assert!(s.journal.since(start).iter().all(|(_, p)| p["index"] == json!(1)), "the journal keeps it: {:?}", s.journal.since(start));
let kept: Vec<Value> = s.journal.since(start).iter().map(|(_, p)| p.clone()).collect();
assert_eq!(kept, [json!({"index": 1}), json!({"indices": [1, 2]}), json!({"index": 1})], "the journal keeps them");
let action = s.journal_for_action(start);
assert_eq!(action, [("artboard.fitToArt".to_string(), json!({})), ("artboard.fitToSelection".to_string(), json!({}))]);
let fit = |id: &str| (id.to_string(), json!({}));
assert_eq!(action, [fit("artboard.fitToArt"), fit("artboard.fitToArt"), fit("artboard.fitToSelection")]);
}
#[test]
+31 -1
View File
@@ -578,7 +578,11 @@ fn collect_merge_flatten_and_reverse() {
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
assert!(s.doc().unwrap().doc.node(col).is_none());
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
// A layer and its sublayer: the sublayer merges into its layer, never the other way round.
run(&mut s, "layer.merge", json!({"ids": [layer.0, sub.0]}));
assert!(s.doc().unwrap().doc.node(sub).is_none());
assert_eq!(children(&s, layer), vec![c, a, g]);
run(&mut s, "edit.undo", json!({}));
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
// Flatten: other visible layers merge in, hidden ones go, templates stay.
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
@@ -595,6 +599,32 @@ fn collect_merge_flatten_and_reverse() {
assert_eq!(undos(&s), before + 1);
}
/// Merge Selected with every row highlighted (a layer, its sublayer and another layer, in panel
/// order) merges the layers into the last top-level one instead of refusing a sublayer target (#2383).
#[test]
fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level_layer() {
for reversed in [false, true] {
let mut s = session();
let (layer, sub, a, _, _, c) = tree(&mut s);
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
let d = rect(&mut s, 500.0);
// Panel order, top first: Layer 2, Layer 1, its sublayer.
let mut rows = vec![l2.0, layer.0, sub.0];
if reversed {
rows.reverse();
}
run(&mut s, "layer.highlight", json!({"ids": rows}));
let into = id(&run(&mut s, "layer.merge", json!({})));
let st = s.doc().unwrap();
assert_eq!(st.doc.layers.len(), 1, "reversed: {reversed}");
assert_eq!(into, st.doc.layers[0].id);
for n in [a, c, d] {
assert!(st.doc.layer_of(n) == Some(into), "{n:?} reversed: {reversed}");
}
assert!(st.doc.node(sub).is_some() || st.doc.node(layer).is_some() || st.doc.node(l2).is_some());
}
}
/// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a
/// layer, and on top of the layer holding its group when it is in a group. When that row is in a
/// hidden or locked layer, the selected art it collects from another layer is deselected, the key
+1 -1
View File
@@ -19,7 +19,7 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
/// placing and listing read).
fn png_header(w: u32, h: u32) -> Vec<u8> {
pub(crate) fn png_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
+50
View File
@@ -331,6 +331,56 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
/// The Selection tool's pie widgets: dragging the End widget from 0° to straight up sets Pie End
/// Angle to 90 in one undo step; on an inverted pie a drag moves only the dragged angle; a
/// double-click on a widget makes the ellipse whole again in one step.
#[test]
fn dragging_and_double_clicking_the_pie_widgets() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = NodeId(s.execute("shape.ellipse", &json!({"x": 50, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(l), .. } => match &**l {
LiveShape::Ellipse { pie, .. } => *pie,
k => panic!("{k:?}"),
},
k => panic!("{k:?}"),
};
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let gesture = |s: &mut Session, events: &[(PointerKind, f64, f64)]| {
for (kind, x, y) in events {
s.pointer(&PointerEvent::new(*kind, *x, *y).with_mods(Mods::default()), v).unwrap();
}
};
// The center is (150, 150); both widgets of the whole ellipse sit 12 pt right of (250, 150).
let n = steps(&s);
gesture(&mut s, &[(PointerKind::Down, 262.0, 150.0), (PointerKind::Drag, 150.0, 20.0), (PointerKind::Up, 150.0, 20.0)]);
assert!(near(pie(&s), (0.0, 90.0)), "{:?}", pie(&s));
assert_eq!(steps(&s), n + 1);
// Inverted, the pie runs from 90° to 360°: dragging the End widget (back at 0°) to 270°
// leaves the start at 90°.
s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap();
assert!(near(pie(&s), (90.0, 360.0)));
gesture(&mut s, &[(PointerKind::Down, 262.0, 150.0), (PointerKind::Drag, 150.0, 280.0), (PointerKind::Up, 150.0, 280.0)]);
assert!(near(pie(&s), (90.0, 270.0)), "{:?}", pie(&s));
// The Start widget sits 12 pt above the top of the outline, at 90°.
let n = steps(&s);
gesture(
&mut s,
&[(PointerKind::Down, 150.0, 88.0), (PointerKind::Up, 150.0, 88.0), (PointerKind::Down, 150.0, 88.0), (PointerKind::Up, 150.0, 88.0)],
);
assert_eq!(steps(&s), n, "two clicks record no undo step");
gesture(&mut s, &[(PointerKind::DoubleClick, 150.0, 88.0)]);
assert_eq!(pie(&s), (0.0, 360.0));
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(pie(&s), (90.0, 270.0)));
}
/// #812: a polygon's Polygon Properties: its angle, radius and side length, each one undo step;
/// Make Sides Equal after an uneven scale; bad values are errors.
#[test]
+293
View File
@@ -705,6 +705,62 @@ fn convert_to_artboards_and_rearrange() {
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
}
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
#[test]
fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
s.execute("artboard.new", &json!({"x": 0, "y": 1000, "width": 300, "height": 300})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
rect(&mut s, 1100.0, 50.0, 100.0, 100.0);
// Across the gap between artboards 1 and 2.
rect(&mut s, 700.0, 250.0, 350.0, 30.0);
// Flush with artboard 1's right edge, and on no artboard.
rect(&mut s, 800.0, 500.0, 100.0, 50.0);
rect(&mut s, 3000.0, 3000.0, 10.0, 10.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let was = boards(&s);
let n = undo_len(&s);
s.execute("artboard.fitToArt", &json!({"indices": [0, 1, 2]})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), Rect::new(700.0, 50.0, 1200.0, 280.0), was[2]]);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(boards(&s), was);
assert!(s.execute("artboard.fitToArt", &json!({"index": 2})).is_err(), "no art on artboard 3");
assert_eq!(boards(&s), was);
s.execute("artboard.fitToArt", &json!({})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
}
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
/// artboard.
#[test]
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (was, n) = (boards(&s), undo_len(&s));
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index");
assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were");
s.execute("artboard.fitToSelection", &json!({})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default");
}
// ---------- Edit Colors ----------
#[test]
@@ -1014,6 +1070,35 @@ fn select_menu_commands_stay_inside_the_isolated_group() {
assert_eq!(selected(&s), vec![a, b, text_inside]);
}
/// In isolation mode the Layers panel's target circle and selection column can't select an object
/// outside the isolated group, so it can't be moved either (#2286).
#[test]
fn layers_panel_selects_nothing_outside_the_isolated_group() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
let b = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
let outside = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
let before = bounds(&s, outside);
let _ = s.execute("layer.target", &json!({"id": outside.0}));
assert!(!selected(&s).contains(&outside));
let _ = s.execute("layer.target", &json!({"id": outside.0, "add": true}));
assert!(!selected(&s).contains(&outside));
let _ = s.execute("layer.selectAll", &json!({"id": outside.0}));
assert!(!selected(&s).contains(&outside));
let _ = s.execute("layer.selectAll", &json!({"id": outside.0, "add": true}));
assert!(!selected(&s).contains(&outside));
let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0}));
assert_eq!(bounds(&s, outside), before);
// Objects inside the group can still be targeted and selected.
s.execute("layer.target", &json!({"id": a.0})).unwrap();
assert_eq!(selected(&s), vec![a]);
s.execute("layer.selectAll", &json!({"id": b.0})).unwrap();
assert_eq!(selected(&s), vec![b]);
}
/// In isolation mode a recalled selection leaves out the objects outside the isolated group, and
/// Next Object Above selects nothing outside it.
#[test]
@@ -1144,6 +1229,141 @@ fn save_and_recall_selection() {
assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err());
}
#[test]
fn update_selection_replaces_the_recalled_saved_selection() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a, b]);
s.execute("select.save", &json!({"name": "A"})).unwrap();
s.execute("select.save", &json!({"name": "Other"})).unwrap();
// Nothing recalled yet: Update Selection is off.
assert!(s.execute("select.update", &json!({})).is_err());
s.execute("select.none", &json!({})).unwrap();
s.execute("select.recall", &json!({"name": "A"})).unwrap();
s.execute("select.add", &json!({"ids": [c.0]})).unwrap();
s.doc_mut().unwrap().mark_saved();
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
assert!(s.doc().unwrap().is_dirty());
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 3);
assert_eq!(selected(&s), vec![a, b, c]);
// Only the recalled one changes.
s.execute("select.recall", &json!({"name": "Other"})).unwrap();
assert_eq!(selected(&s), vec![a, b]);
// It travels with the file.
let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
assert_eq!(loaded.saved_selections.iter().map(|x| x.objects.len()).collect::<Vec<_>>(), [3, 2]);
// It is one undo step.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.recall", &json!({"name": "A"})).unwrap();
assert_eq!(selected(&s), vec![a, b]);
// Nothing selected: Update Selection is off.
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("select.update", &json!({})).is_err());
// It can run again without another recall.
sel(&mut s, &[c]);
s.execute("select.update", &json!({})).unwrap();
sel(&mut s, &[b]);
s.execute("select.update", &json!({})).unwrap();
s.execute("select.recall", &json!({"name": "A"})).unwrap();
assert_eq!(selected(&s), vec![b]);
}
#[test]
fn update_selection_follows_a_rename_and_stops_after_a_delete() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
s.execute("select.save", &json!({"name": "One"})).unwrap();
s.execute("select.recall", &json!({"name": "One"})).unwrap();
s.execute("select.editSaved", &json!({"name": "One", "newName": "Uno"})).unwrap();
sel(&mut s, &[a, b]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "Uno");
s.execute("select.recall", &json!({"name": "Uno"})).unwrap();
assert_eq!(selected(&s), vec![a, b]);
// A saved selection of the same name saved after the delete is not the recalled one.
s.execute("select.editSaved", &json!({"name": "Uno", "delete": true})).unwrap();
s.execute("select.save", &json!({"name": "Uno"})).unwrap();
assert!(s.execute("select.update", &json!({})).is_err());
}
#[test]
fn update_selection_changes_the_recalled_saved_selection_after_undo_and_redo() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
let recall = |s: &mut Session, name: &str| {
s.execute("select.recall", &json!({"name": name})).unwrap();
selected(s)
};
sel(&mut s, &[a]);
s.execute("select.save", &json!({"name": "A"})).unwrap();
sel(&mut s, &[b]);
s.execute("select.save", &json!({"name": "B"})).unwrap();
recall(&mut s, "A");
// Swap the two names, then undo the swap: Update Selection still changes the one recalled.
let swap = json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}]});
s.execute("select.editSaved", &swap).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[a, c]);
s.execute("select.update", &json!({})).unwrap();
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![a, c], vec![b]));
// Undo and redo a rename of the recalled one.
recall(&mut s, "A");
s.execute("select.editSaved", &json!({"name": "A", "newName": "First"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
sel(&mut s, &[c]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "First");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[c]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
assert_eq!(recall(&mut s, "A"), vec![c]);
// Undo and redo an Edit Selection… that renamed it and deleted the other one.
recall(&mut s, "A");
let edits = json!({"edits": [{"name": "A", "newName": "First"}, {"name": "B", "delete": true}]});
s.execute("select.editSaved", &edits).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
sel(&mut s, &[a]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "First");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[a]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![a], vec![b]));
// A cancelled undo group that renamed it.
recall(&mut s, "B");
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
s.execute("select.editSaved", &json!({"name": "B", "newName": "Second"})).unwrap();
s.execute("edit.undoGroup.end", &json!({"cancel": true})).unwrap();
sel(&mut s, &[a, b]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "B");
assert_eq!(recall(&mut s, "B"), vec![a, b]);
// Undo an Update Selection that gave it the other one's objects.
recall(&mut s, "A");
sel(&mut s, &[a, b]);
s.execute("select.update", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[c]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![c], vec![a, b]));
// Save Selection made after the recall, and undone.
recall(&mut s, "A");
s.execute("select.save", &json!({"name": "C"})).unwrap();
sel(&mut s, &[b]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[a]);
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
}
#[test]
fn edit_selection_needs_a_saved_selection_and_a_free_name() {
let mut s = session();
@@ -1447,6 +1667,61 @@ fn make_release_clear_guides() {
assert_eq!(undo_len(&s), n + 1);
}
/// The anchor positions of `id`, which must be a path of one open subpath, and whether it is still a guide.
fn line_ends(s: &Session, id: NodeId) -> (Vec<(f64, f64)>, bool) {
let NodeKind::Path { path, guide, .. } = &node(s, id).kind else { panic!("not a path") };
let [sub] = &path.subpaths[..] else { panic!("not one subpath") };
assert!(!sub.closed);
(sub.anchors.iter().map(|a| (a.p.x, a.p.y)).collect(), *guide)
}
/// Release Guides turns ruler guides into paths with no fill or stroke, each in one undo step. An
/// artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the
/// bounds of all the artboards.
#[test]
fn release_guides_turns_ruler_guides_into_paths() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
let g = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[g]);
s.execute("view.guides.make", &json!({})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 50, "artboard": 0})).unwrap();
let d = &s.doc().unwrap().doc;
let (ab, all) = (d.artboards[0].rect, d.artboards[0].rect.union(d.artboards[1].rect));
assert!(all.x1 > ab.x1);
let layer = s.doc().unwrap().current_layer();
// The selected artboard guide goes, and a selected path across its artboard takes its place.
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
let n = undo_len(&s);
assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1);
assert_eq!((guides(&s), undo_len(&s)), (vec![(false, 300.0)], n + 1));
let [line] = selected(&s)[..] else { panic!("one path selected") };
assert_eq!(line_ends(&s, line), (vec![(ab.x0, 50.0), (ab.x1, 50.0)], false));
assert_eq!(node(&s, line).appearance, Appearance::basic(Paint::None, Paint::None, 0.0));
assert_eq!(s.doc().unwrap().doc.parent_of(line), layer);
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: true, .. }), "the guide path stays a guide");
// Undo brings the guide back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s), vec![(false, 300.0), (false, 50.0)]);
assert!(s.doc().unwrap().doc.node(line).is_none());
// index releases the canvas guide as a line across the artboards.
assert_eq!(s.execute("view.guides.release", &json!({"index": 0})).unwrap()["count"], 1);
assert_eq!(guides(&s), vec![(false, 50.0)]);
let [line] = selected(&s)[..] else { panic!("one path selected") };
assert_eq!(line_ends(&s, line), (vec![(all.x0, 300.0), (all.x1, 300.0)], false));
// ids releases those guide paths.
assert_eq!(s.execute("view.guides.release", &json!({"ids": [g.0]})).unwrap()["count"], 1);
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: false, .. }));
assert_eq!(selected(&s), vec![g]);
// Locked guides are not released by index or ids.
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
for p in [json!({"index": 0}), json!({"ids": [g.0]})] {
assert!(matches!(s.execute("view.guides.release", &p), Err(EngineError::Other(m)) if m == "guides are locked"), "{p}");
}
assert_eq!(guides(&s).len(), 1);
}
#[test]
fn ruler_guides_and_lock() {
let mut s = session();
@@ -2173,6 +2448,24 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
assert_eq!(bounds(&s, img), b);
}
#[test]
fn crop_image_trim_checks_the_rgba_size_before_decoding() {
// 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA
// (4 bytes a pixel) they are just over or just within the 2 GB allowance.
let trim = |w: u32, h: u32| {
let png = super::tests_linkspanel::png_header(w, h);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string()
};
let over = trim(23_171, 23_170);
assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}");
// One column fewer is within the allowance: it is decoded, and its missing rows can't be read.
let within = trim(23_170, 23_170);
assert!(within.contains("the image's pixels can't be read"), "{within}");
}
#[test]
fn crop_image_trim_leaves_a_cmyk_image_alone() {
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
+11
View File
@@ -225,6 +225,17 @@ fn saved_presets_live_in_the_preferences() {
assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err());
}
#[test]
fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() {
let mut s = Session::new();
let saved = json!({"newDocPresets": [{"name": "Zero", "rasterEffectsPpi": 0}, {"name": "Huge", "rasterEffectsPpi": 5000}]});
s.apply_prefs(serde_json::from_value(saved).unwrap());
let ppi: Vec<Option<f64>> = presets(&mut s, "Saved").iter().map(|p| p["rasterEffectsPpi"].as_f64()).collect();
assert_eq!(ppi, [Some(1.0), Some(2400.0)]);
s.execute("file.new", &json!({"preset": "Zero"})).unwrap();
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0);
}
#[test]
fn rearrange_keeps_its_grid() {
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
+96
View File
@@ -0,0 +1,96 @@
//! Outline Object (`path.outlineObject`, Effect › Path › Outline Object): type that carries it is
//! drawn, exported and expanded as its glyph outlines while it stays editable type.
use serde_json::json;
use vectorcraft_doc::{Node, NodeKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// Selected point type "OUTLINE" (48 pt) at (40, 100).
fn text(s: &mut Session) -> NodeId {
let r = s.execute("text.create", &json!({"x": 40, "y": 100, "text": "OUTLINE", "size": 48})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn apply(s: &mut Session, id: NodeId) {
s.execute("effect.apply", &json!({"effect": "path.outlineObject", "ids": [id.0], "item": null})).unwrap();
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn count_text(n: &Node) -> usize {
let mut k = 0;
n.walk(&mut |m| k += usize::from(matches!(m.kind, NodeKind::Text(_))));
k
}
#[test]
fn outline_object_is_applied_in_one_undo_step_and_takes_no_parameters() {
let mut s = session();
let id = text(&mut s);
apply(&mut s, id);
let ids: Vec<String> = node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect();
assert_eq!(ids, ["path.outlineObject"]);
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Outline Object"));
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, id).appearance.effects.is_empty());
let err = s.execute("effect.apply", &json!({"effect": "path.outlineObject", "ids": [id.0], "params": {"width": 1}})).unwrap_err().to_string();
assert!(err.contains("`width`") && err.contains("takes no parameters"), "{err}");
assert!(node(&s, id).appearance.effects.is_empty());
}
#[test]
fn type_with_outline_object_stays_editable_type_and_keeps_its_bounds() {
let mut s = session();
let id = text(&mut s);
let before = [false, true].map(|preview| {
s.prefs.use_preview_bounds = preview;
s.transform_bounds(&[id])
});
apply(&mut s, id);
let after = [false, true].map(|preview| {
s.prefs.use_preview_bounds = preview;
s.transform_bounds(&[id])
});
assert_eq!(after, before);
// A native save keeps the effect.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let ids: Vec<String> = back.node(id).unwrap().appearance.effects.iter().map(|e| e.id.clone()).collect();
assert_eq!(ids, ["path.outlineObject"]);
// The type is still type, drawn as its outlines, and a text edit changes them.
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Text(_)));
let drawn = vectorcraft_render::effects::reshape(&n, None).unwrap();
assert_eq!(count_text(&drawn), 0);
s.execute("text.setText", &json!({"id": id.0, "text": "OUTLINED TYPE"})).unwrap();
let longer = vectorcraft_render::effects::reshape(&node(&s, id), None).unwrap();
assert!(longer.geometric_bounds().unwrap().width() > drawn.geometric_bounds().unwrap().width() + 50.0);
}
#[test]
fn type_with_outline_object_exports_and_expands_as_paths() {
let mut s = session();
let id = text(&mut s);
apply(&mut s, id);
let doc = s.doc().unwrap().doc.clone();
let svg = vectorcraft_svg::export(&doc, &Default::default());
assert!(!svg.contains("<text") && !svg.contains("OUTLINE"), "{svg}");
// Expand Appearance leaves paths and no type, in one undo step.
let undo = s.doc().unwrap().history.undo.len();
s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let doc = &s.doc().unwrap().doc;
let layer = &doc.layers[0];
assert_eq!(count_text(layer), 0);
let mut paths = 0;
layer.walk(&mut |m| paths += usize::from(matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
assert!(paths > 0);
}
+21
View File
@@ -130,6 +130,27 @@ fn duplicate_artboard_copies_its_art() {
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
}
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
/// artboards, the art and the clipboard stay as they were.
#[test]
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
let mut s = session();
rect(&mut s);
s.execute("artboard.new", &json!({})).unwrap();
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let st = s.doc().unwrap();
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied");
}
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
/// is one undo step, and the tool takes the pasted artboard.
+98 -13
View File
@@ -391,13 +391,22 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
("pen", Cursor::Pen),
("addAnchor", Cursor::PenAdd),
("deleteAnchor", Cursor::PenDelete),
("anchorPoint", Cursor::Pen),
("curvature", Cursor::Pen),
("mesh", Cursor::PenAdd),
("anchorPoint", Cursor::AnchorPoint),
("curvature", Cursor::Curvature),
("mesh", Cursor::MeshAdd),
("eyedropper", Cursor::Eyedropper),
("slice", Cursor::Slice),
("blend", Cursor::Blend),
("shapeBuilder", Cursor::ShapeBuilder),
("paintbrush", Cursor::Paintbrush),
("blobBrush", Cursor::BlobBrush),
("shaper", Cursor::Shaper),
("pencil", Cursor::Pencil),
("smooth", Cursor::Smooth),
("pathEraser", Cursor::PathEraser),
("join", Cursor::Join),
("eraser", Cursor::Eraser),
("knife", Cursor::Knife),
];
for (tool, c) in own {
assert_eq!(cursor(&mut s, tool), c, "{tool} off");
@@ -415,15 +424,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
"polygon",
"star",
"flare",
"paintbrush",
"blobBrush",
"shaper",
"pencil",
"smooth",
"pathEraser",
"join",
"eraser",
"knife",
"mirrorCut",
"lineCut",
"rectCut",
@@ -440,7 +440,7 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on");
}
assert_eq!(cursor(&mut s, "type"), Cursor::Text);
assert_eq!(cursor(&mut s, "scissors"), Cursor::NotAllowed);
assert_eq!(cursor(&mut s, "scissors"), Cursor::ScissorsNotAllowed);
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
for (tool, c) in own {
assert_eq!(cursor(&mut s, tool), c, "{tool} off again");
@@ -467,6 +467,8 @@ fn precise_cursors_replace_every_pen_state() {
Cursor::BlendAnchor,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
Cursor::AnchorPoint,
Cursor::Curvature,
] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
@@ -1024,6 +1026,33 @@ fn type_increments_step_the_type() {
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (22.0, 25.0));
}
/// Cmd/Ctrl+Alt+Shift+. and , step the font size by five times the Size/Leading increment, on
/// the selected type objects and on the Type tool's selected text.
#[test]
fn font_size_steps_by_five_increments() {
use vectorcraft_tools::PointerKind::{Down, Up};
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 12})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
assert_eq!(find_command("type.size.increaseByFive").unwrap().shortcut, Some("Cmd+Alt+Shift+."));
assert_eq!(find_command("type.size.decreaseByFive").unwrap().shortcut, Some("Cmd+Alt+Shift+,"));
s.execute("type.size.increaseByFive", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).size, 22.0, "five steps of the 2 pt Size/Leading increment");
s.execute("type.size.decreaseByFive", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).size, 12.0);
set_pref(&mut s, "typeSizeIncrement", json!(3));
s.execute("type.size.increaseByFive", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 10).size, 27.0, "the step follows the preference");
// While the Type tool edits, only its selected text steps.
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
gesture(&mut s, v, &[(Down, 102.0, 95.0), (Up, 102.0, 95.0)]);
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
s.set_tool_option("select", &json!({"start": 6, "end": 11}));
s.execute("type.size.decreaseByFive", &json!({})).unwrap();
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (27.0, 12.0));
}
/// Cmd/Ctrl+Alt+Q resets tracking to 0 and kerning to Auto (0 in the Character panel) in every
/// selected type object and in the Type tool's selected text. At a caret it resets only the
/// kerning of the character before it. It is one undo step. Type objects given in `ids` are reset
@@ -1069,6 +1098,50 @@ fn reset_tracking_sets_tracking_and_kerning_to_zero() {
assert_eq!(style_at(&s, other, 0).tracking, 0.0, "the reset stays");
}
/// Cmd/Ctrl+Shift+X resets the horizontal scale to 100% in every selected type object and in the
/// Type tool's selected text, as one undo step, and leaves the vertical scale as it is. At a caret
/// it leaves the text as it is. On a stretched object the scale the Character panel shows, which includes the
/// object's own stretch, goes to 100%. Type objects given in `ids` are reset in full while the
/// Type tool edits.
#[test]
fn reset_horizontal_scale_sets_it_to_100_percent() {
use vectorcraft_tools::PointerKind::{Down, Up};
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 20})).unwrap()["id"].as_u64().unwrap());
let scales = |s: &Session, byte| {
let st = style_at(s, id, byte);
(st.h_scale, st.v_scale)
};
s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150, "vScale": 80})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
assert_eq!(find_command("type.resetHorizontalScale").unwrap().shortcut, Some("Cmd+Shift+X"));
assert_eq!(s.execute("type.resetHorizontalScale", &json!({})).unwrap()["ids"], json!([id.0]));
assert_eq!((scales(&s, 0), scales(&s, 10)), ((100.0, 80.0), (100.0, 80.0)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(scales(&s, 0), (150.0, 80.0), "one undo step");
// A type object stretched to twice its width shows 200% and keeps half of it stored.
let wide = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Wide", "size": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [wide.0]})).unwrap();
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
s.execute("type.resetHorizontalScale", &json!({})).unwrap();
assert_eq!(style_at(&s, wide, 0).h_scale, 50.0);
// With the Type tool, it resets only the selected text.
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
gesture(&mut s, v, &[(Down, 102.0, 95.0), (Up, 102.0, 95.0)]);
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
s.set_tool_option("select", &json!({"start": 0, "end": 5}));
s.execute("type.resetHorizontalScale", &json!({})).unwrap();
assert_eq!((scales(&s, 4), scales(&s, 6)), ((100.0, 80.0), (150.0, 80.0)));
// At a caret it leaves the text as it is.
s.set_tool_option("select", &json!({"start": 8, "end": 8}));
assert_eq!(s.execute("type.resetHorizontalScale", &json!({})).unwrap()["ids"], json!([]));
assert_eq!((scales(&s, 7), scales(&s, 8)), ((150.0, 80.0), (150.0, 80.0)));
// While the Type tool edits, the type objects in `ids` are reset in full.
s.execute("type.resetHorizontalScale", &json!({"ids": [id.0]})).unwrap();
assert_eq!((scales(&s, 6), scales(&s, 10)), ((100.0, 80.0), (100.0, 80.0)));
}
/// Type › Fill New Type Objects With Placeholder Text (#394, on by default): type the Type tool
/// places starts with placeholder text, selected, so typing replaces it; off, it starts empty.
#[test]
@@ -1213,6 +1286,18 @@ fn show_font_names_in_english_preference_is_stored() {
assert!(s.prefs.font_names_in_english);
}
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so OpenType
/// substitutions are not pink on every canvas until the user turns the preference on.
#[test]
fn highlight_alternate_glyphs_preference_is_off_by_default() {
let mut s = Session::new();
assert!(!s.prefs.highlight_alternate_glyphs, "off by default");
set_pref(&mut s, "highlightAlternateGlyphs", json!(true));
assert!(s.prefs.highlight_alternate_glyphs);
set_pref(&mut s, "highlightAlternateGlyphs", json!(false));
assert!(!s.prefs.highlight_alternate_glyphs);
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
+22
View File
@@ -130,3 +130,25 @@ fn type_in_a_missing_family_warns_as_pdf_export_does() {
assert_eq!(fallback(&v), export, "{settings}");
}
}
#[test]
fn type_that_does_not_fit_its_frame_is_named() {
let (mut s, _) = session();
let p = json!({"x": 10, "y": 100, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}});
let id = s.execute("text.create", &p).unwrap()["id"].clone();
let over = |v: &Value| -> Vec<String> {
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("fit its frame")).map(str::to_string).collect()
};
let (_, v) = print(&mut s, json!({}));
assert_eq!(over(&v), [format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {id})")]);
assert_eq!(over(&v), over(&s.execute("print.preview", &json!({})).unwrap()));
// Type printed as an image is named too.
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
for settings in [json!({}), json!({"advanced": {"printAsBitmap": true}})] {
let (_, v) = print(&mut s, json!({ "settings": settings }));
assert_eq!(over(&v).len(), 1, "{settings}");
}
s.execute("text.areaOptions", &json!({"ids": [id], "height": 150})).unwrap();
let (_, v) = print(&mut s, json!({}));
assert_eq!(over(&v), Vec::<String>::new());
}
+91
View File
@@ -140,6 +140,97 @@ fn type_that_does_not_fit_its_frame_counts_only_where_it_shows() {
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new());
}
/// Area type 100 × 20 pt at (`x`, `y`) holding four lines, of which the first shows → its id.
fn overset(s: &mut Session, x: f64, y: f64) -> u64 {
let v = s.execute("text.create", &json!({"x": x, "y": y, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap();
assert_eq!(v["overflow"], json!([v["id"]]), "{v}");
v["id"].as_u64().unwrap()
}
/// `print.preview {settings}`'s warnings about type that doesn't fit its frame.
fn preview_overflow(s: &mut Session, settings: Value) -> Vec<String> {
let v = s.execute("print.preview", &json!({ "settings": settings })).unwrap();
let over = |w: &&str| w.contains("fit its frame") || w.contains("fit their frames");
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(over).map(str::to_string).collect()
}
#[test]
fn type_that_does_not_fit_its_frame_is_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let id = overset(&mut s, 10.0, 10.0);
let one = format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {id})");
assert_eq!(preview_overflow(&mut s, json!({})), [one]);
// A frame that holds its text, or a hidden object, leaves nothing out.
s.execute("text.areaOptions", &json!({"ids": [id], "height": 150})).unwrap();
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new());
s.execute("text.areaOptions", &json!({"ids": [id], "height": 20})).unwrap();
s.execute("object.setProps", &json!({"ids": [id], "visible": false})).unwrap();
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new());
s.execute("object.setProps", &json!({"ids": [id], "visible": true})).unwrap();
// Type on a path that runs past the path's end.
let path = s.execute("path.create", &json!({"d": "M10 150 L60 150"})).unwrap()["id"].clone();
let on = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Longer than its path"})).unwrap();
assert_eq!(on["overflow"], json!([on["id"]]), "{on}");
let both = format!("2 text objects don't fit their frames: the text past the end of each frame doesn't print (ids {id}, {})", on["id"]);
assert_eq!(preview_overflow(&mut s, json!({})), [both]);
// Past 20, the first 20 ids and how many more.
let mut ids = vec![id, on["id"].as_u64().unwrap()];
ids.extend((0..20).map(|i| overset(&mut s, 150.0, f64::from(i) * 5.0)));
let named: Vec<String> = ids[..20].iter().map(u64::to_string).collect();
let many =
format!("22 text objects don't fit their frames: the text past the end of each frame doesn't print (ids {} and 2 more)", named.join(", "));
assert_eq!(preview_overflow(&mut s, json!({})), [many]);
}
#[test]
fn type_that_does_not_fit_counts_only_layers_and_artboards_that_print() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
let r = s.doc().unwrap().doc.artboards[1].rect;
overset(&mut s, r.x0 + 10.0, r.y0 + 10.0);
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "1"})), Vec::<String>::new());
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "2"})).len(), 1);
// On a hidden layer, the type prints only with Print Layers: All.
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
overset(&mut s, 10.0, 10.0);
s.execute("layer.setProps", &json!({"id": layer, "visible": false})).unwrap();
for (layers, warns) in [("visiblePrintable", false), ("visible", false), ("all", true)] {
let warned = preview_overflow(&mut s, json!({"artboards": "range", "range": "1", "printLayers": layers}));
assert_eq!(!warned.is_empty(), warns, "{layers}");
}
// A layer that doesn't print.
s.execute("layer.setProps", &json!({"id": layer, "visible": true, "printable": false})).unwrap();
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "1"})), Vec::<String>::new());
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "1", "printLayers": "visible"})).len(), 1);
}
#[test]
fn type_that_does_not_fit_is_named_only_among_the_objects_of_the_document() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let id = overset(&mut s, 10.0, 10.0);
assert_eq!(preview_overflow(&mut s, json!({})).len(), 1);
s.execute("symbol.new", &json!({"name": "Label", "ids": [id]})).unwrap();
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new(), "the art of a symbol");
}
#[test]
fn a_threaded_story_is_named_only_when_its_last_frame_does_not_hold_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog. ".repeat(8);
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap();
assert_eq!(a["overflow"], json!([a["id"]]), "{a}");
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [a["id"], b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new(), "the second frame holds the rest");
s.execute("text.editRange", &json!({"id": a["id"], "start": 0, "end": 0, "insert": story.repeat(4)})).unwrap();
let warned = preview_overflow(&mut s, json!({}));
assert!(warned.len() == 1 && warned[0].ends_with(&format!("(id {b})")), "{warned:?}");
}
#[test]
fn type_of_an_artboard_larger_than_the_paper_counts_only_on_the_paper() {
let mut s = Session::new();
+14
View File
@@ -111,3 +111,17 @@ fn type_in_a_missing_family_is_named_when_its_artboard_prints() {
let (_, v) = print_ps(&mut s, json!({ "settings": paper }));
assert_eq!(fallback(&v), 0, "{v}");
}
#[test]
fn type_that_does_not_fit_its_frame_is_named_when_its_artboard_prints() {
let mut s = session(2);
let r = s.doc().unwrap().doc.artboards[1].rect;
let p = json!({"x": r.x0 + 10.0, "y": r.y0 + 10.0, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}});
let id = s.execute("text.create", &p).unwrap()["id"].clone();
let over = |v: &Value| v["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("fit its frame")).count();
let (_, v) = print_ps(&mut s, json!({}));
assert_eq!(over(&v), 1, "{v}");
assert!(v["warnings"].to_string().contains(&format!("(id {id})")), "{v}");
let (_, v) = print_ps(&mut s, json!({"settings": {"artboards": "range", "range": "1"}}));
assert_eq!(over(&v), 0, "{v}");
}
+30 -3
View File
@@ -1,5 +1,5 @@
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes,
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Texture ›
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
//! as images of the effected object.
@@ -42,6 +42,9 @@ const DISTORT: [(&str, &str); 3] =
const PIXELATE: [(&str, &str); 4] =
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
/// Effect › Sketch, at their defaults.
const SKETCH: [(&str, &str); 1] = [("sketch.halftonePattern", "{}")];
/// Effect › Texture, at their defaults.
const TEXTURE: [(&str, &str); 6] = [
("texture.craquelure", "{}"),
@@ -97,6 +100,7 @@ fn listed_applied_edited_and_undone() {
(BRUSH_STROKES.as_slice(), "Brush Strokes"),
(DISTORT.as_slice(), "Distort"),
(PIXELATE.as_slice(), "Pixelate"),
(SKETCH.as_slice(), "Sketch"),
(TEXTURE.as_slice(), "Texture"),
(VIDEO.as_slice(), "Video"),
([("video.ntscColors", "{}")].as_slice(), "Video"),
@@ -134,7 +138,9 @@ fn listed_applied_edited_and_undone() {
#[test]
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
for (id, params) in EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
for (id, params) in
EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO)
{
let (mut s, g) = striped();
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
@@ -150,6 +156,25 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
}
}
/// Halftone Pattern's Size, Contrast and Pattern Type are saved with the document and draw the same
/// when it opens again.
#[test]
fn halftone_pattern_settings_survive_a_save_and_open() {
let (mut s, g) = striped();
let set = json!({"size": 7.0, "contrast": 20.0, "patternType": "line"});
s.execute("effect.apply", &json!({"effect": "sketch.halftonePattern", "ids": [g], "params": set})).unwrap();
let drawn = render(&s);
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, set);
assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, drawn.pixels);
// Each setting shows: changing any one draws differently.
for change in [json!({"size": 3.0}), json!({"contrast": 50.0}), json!({"patternType": "dot"})] {
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": change})).unwrap();
assert!(mean_diff(&drawn, &render(&s)) > 0.001, "{change}");
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": set})).unwrap();
}
}
/// A PNG of rings around its center, 12 × 10 pixels.
fn rings_png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(12, 10, |x, y| {
@@ -251,7 +276,9 @@ fn spin_blur_reaches_past_the_corners() {
#[test]
fn pdf_and_svg_write_the_effected_object_as_an_image() {
for (id, params) in EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
for (id, params) in
EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO)
{
let (mut s, g) = striped();
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
+100
View File
@@ -2,6 +2,7 @@
//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults.
use serde_json::{Value, json};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings};
use super::*;
@@ -98,6 +99,60 @@ fn add_around_grows_the_effect_image() {
assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72));
}
/// The transforms of the images in `doc` flattened for output.
fn image_transforms(doc: &Document) -> Vec<[f64; 6]> {
let mut xfs = vec![];
crate::flatten_raster_effects(doc).unwrap().walk(|n| {
if let NodeKind::Image(im) = &n.kind {
xfs.push(im.xf.as_coeffs());
}
});
xfs
}
#[test]
fn a_file_with_add_around_out_of_range_opens_with_it_in_range() {
let mut s = session();
shadowed(&mut s);
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects.add_around = 1e300;
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND);
let xfs = image_transforms(&back);
assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}");
// 1,000 pt on every side at 72 ppi.
assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000);
d.raster_effects.add_around = -5.0;
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0);
}
#[test]
fn art_too_large_for_f64_gets_no_effect_image() {
let mut s = session();
let r = shadowed(&mut s);
// Settings out of range in a document made without opening a file.
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects.add_around = 1e300;
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
let mut d = (*s.doc().unwrap().doc).clone();
let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") };
(*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None);
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
// Expand Appearance of it by id, with nothing selected, puts no image into the document (the
// rectangle at its own size gets one).
let normal = (*s.doc().unwrap().doc).clone();
let mut expanded_images = |d: Document| {
let st = s.doc_mut().unwrap();
(st.doc, st.selection) = (std::sync::Arc::new(d), Default::default());
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
let mut images = 0;
s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
images
};
assert_eq!(expanded_images(normal), 1);
assert_eq!(expanded_images(d), 0);
}
#[test]
fn the_color_model_and_anti_alias_reach_the_pixels() {
let mut s = session();
@@ -158,3 +213,48 @@ fn raster_settings_round_trip_and_old_files_load() {
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default()));
}
/// Save the active document with the raster effects resolution `ppi` and open the file.
fn reopen_with_ppi(s: &mut Session, ppi: f64) {
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects_ppi = ppi;
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap();
}
#[test]
fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() {
for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] {
let mut s = session();
let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
reopen_with_ppi(&mut s, saved);
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}");
assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened);
assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened);
// Rasterize without a ppi param renders the 10 pt square at it.
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let out = s.execute("object.rasterize", &json!({})).unwrap();
assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}");
}
}
#[test]
fn a_gradient_exports_at_the_resolution_the_file_opens_with() {
for saved in [0.0, -5.0] {
let mut s = session();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap());
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap();
reopen_with_ppi(&mut s, saved);
// EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi.
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0);
let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap();
let mut sizes = vec![];
vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
sizes.push((im.width, im.height));
}
});
assert_eq!(sizes, [(10, 10)], "saved as {saved}");
}
}
+94
View File
@@ -157,6 +157,20 @@ fn shaper_export_selection_crops_to_the_shown_art() {
assert_eq!(v["bounds"], json!([b.x0, b.y0, b.width(), b.height()]), "{v}");
}
/// Fit to Artwork Bounds fits the art a Shaper group shows, not its transparent source art.
#[test]
fn shaper_fit_to_artwork_bounds_fits_the_shown_art() {
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap();
scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]);
let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]);
s.execute("artboard.fitToArt", &json!({})).unwrap();
let b = node(&s, g).visual_bounds().unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, b, "the circle and the trimmed line");
}
#[test]
fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() {
let mut s = session();
@@ -217,6 +231,86 @@ fn shaper_double_click_selects_an_original_for_construction_editing() {
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
}
/// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with
/// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source
/// shape, and the mode lasts through undo and redo of an edit made in it.
#[test]
fn shaper_widget_switches_between_face_selection_and_construction_mode() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
let sources = node(&s, g).children().unwrap()[0].id;
let v = ViewInfo { smart_guides: false, ..Default::default() };
let widget = |s: &Session| {
let st = s.doc().unwrap();
vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, v.zoom).unwrap()
};
let click = |s: &mut Session, p: Point| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap();
}
};
for tool in ["shaper", "selection"] {
s.select_tool(tool, v).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let w = widget(&s);
assert!(!w.construction);
click(&mut s, w.at);
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}");
assert!(widget(&s).construction);
if tool == "shaper" {
click(&mut s, Point::new(25.0, 50.0));
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
} else {
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
}
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}: undo");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}: redo");
assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000"));
let w = widget(&s);
assert!(w.construction);
click(&mut s, w.at);
assert_eq!(s.doc().unwrap().isolation, None, "{tool}");
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
s.execute("object.move", &json!({"dx": -10, "dy": 0})).unwrap();
}
}
/// In Construction mode a Shaper tap picks a source shape of the isolated group only, even where
/// another Shaper Group's source shapes lie above it.
#[test]
fn shaper_construction_taps_stay_in_the_isolated_group() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
let c = rectangle(&mut s, 20.0, "#00ff00");
let d = rectangle(&mut s, 70.0, "#ffff00");
let upper = NodeId(
s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert_ne!(lower, upper);
let sources = node(&s, lower).children().unwrap()[0].id;
s.execute("object.isolate", &json!({"id": lower.0})).unwrap();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 25.0, 50.0), v).unwrap();
}
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().isolation, Some(sources));
// Outside the isolated group's source shapes, a tap changes nothing.
s.execute("shaper.select", &json!({"point": [160, 50], "source": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().isolation, Some(sources));
}
#[test]
fn shaper_lines_keep_free_angles_with_or_without_shift() {
for shift in [false, true] {
+4 -1
View File
@@ -52,12 +52,15 @@ fn mixed_values_and_align_stroke_follow_the_selection() {
select(&mut s, &[a, b]);
let m = s.active().unwrap().stroke_mixed();
assert!(m.weight && m.join && !m.cap && !m.miter_limit && !m.align && !m.dash && m.can_align, "{m:?}");
// An open path or type in the selection can't take an inside or outside stroke.
// An open path in the selection can't take an inside or outside stroke; type can (its
// characters' stroke goes around the glyphs).
let line = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap());
select(&mut s, &[a, line]);
assert!(!s.active().unwrap().stroke_mixed().can_align);
let text = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap());
select(&mut s, &[text]);
assert!(s.active().unwrap().stroke_mixed().can_align);
select(&mut s, &[text, line]);
assert!(!s.active().unwrap().stroke_mixed().can_align);
// Grouped closed paths can.
select(&mut s, &[a, b]);
+20
View File
@@ -737,3 +737,23 @@ fn a_colour_previewed_while_typing_keeps_the_typing() {
s.tool_text("d", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abcd");
}
/// The red out port of selected overflowing area type is drawn with the Selection tool too, and
/// not twice with the Type tool, which draws it itself (#2323).
#[test]
fn overflow_marker_shows_with_the_selection_tool() {
let mut s = session();
let v = ViewInfo::default();
let long = "Words that fill more than the frame holds. ".repeat(20);
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("selection", v).unwrap();
let red = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [230, 30, 30], .. })).count();
assert_eq!(red(&mut s), 1, "the out port");
s.select_tool("type", v).unwrap();
assert_eq!(red(&mut s), 1, "the Type tool's own, not a second one");
s.execute("select.set", &json!({"ids": []})).unwrap();
s.select_tool("selection", v).unwrap();
assert_eq!(red(&mut s), 0, "nothing selected");
}
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more