Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 438f90c751 Part of #641: Image Trace: Create Strokes
Advanced › Create: Fills and/or Strokes, with Stroke (the widest line, px).
With Strokes, each colour's 8-connected parts no wider than the stroke width
(chamfer distance transform) trace as open centre lines instead of filled
outlines: the line is thinned to one pixel (Zhang-Suen with each removal
re-checked, so two-pixel lines survive; staircase corners dropped), its
skeleton becomes runs between ends and junctions (junction pixels merged,
spurs pruned, runs through two-way junctions joined, rings closed), and each
run is simplified and fitted with curves. The stroke width is the line's mean
width (pixels over length). Traced lines get round caps and joins in the
document. Without Fills, wider areas become 1 px stroked outlines.
`imageTrace.make` takes fills, strokes and strokeWidth, and refuses neither
fills nor strokes and a non-positive width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:11:09 +07:00
BFlat 11fceda13e Merge pull request #650 from mateususa/fix/artboard-tool-delete-key
Artboard tool: Delete and Backspace delete the active artboard
2026-10-09 09:31:38 +07:00
BFlat ea814b3fac Merge pull request #652 from storytold/fix/649-dock-drop-zone
Fix #649: a panel dropped on the dock lights up where it goes
2026-10-09 09:27:11 +07:00
bflatasticandClaude Opus 5.5 b5e39048df Fix #649: a panel dropped on the dock lights up where it goes
Panels other than Properties, Layers and Libraries live in the dock's icon
column, not as tabs. Dragging one onto the dock lit up the whole dock, tabs
included, and the drop put it back as an icon, so it seemed to vanish. The
drop zone now lights up the icon column for such panels (the whole dock still
for the tabbed ones), and the dropped panel pops out of its icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:23:30 +07:00
mateususaandClaude Opus 5.5 762bd6b927 Artboard tool: Delete and Backspace delete the active artboard
The Artboard tool handled Delete/Backspace in Tool::key, but never claimed
the keys through Tool::claims_key. The UI only routes Delete/Backspace to a
tool that claims them, so the keypress went to the Clear shortcut instead
and the artboard stayed. The existing tests called the tool directly and
missed it.

The tool now claims Delete/Backspace while no drag is in progress and more
than one artboard exists. With only one artboard left, the keys stay with
the shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:19:59 -07:00
BFlat 033ceaff41 Merge pull request #648 from storytold/fix/svg-root-layer-name
Part of #372: SVG import names the one layer from the root element
2026-10-09 09:05:21 +07:00
bflatasticandClaude Opus 5.5 a2e4206155 Part of #372: SVG import names the one layer from the root element
Other editors export a document with a single layer without a layer group:
the layer's name goes on the root <svg> (id, or data-name) and its art sits
directly inside. Such a file opened as "Layer 1"; now its one layer takes the
root's name. Files whose top-level groups are the layers are unchanged, and an
Inkscape root's generated id (with inkscape:version) isn't taken as a name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:00:24 +07:00
BFlat 771981e2b1 Merge pull request #644 from dotCooCoo/feat/ase-import
Swatches: swatch exchange (.ase) files open as swatch libraries
2026-10-09 08:50:50 +07:00
BFlat c7aeaa637f Merge pull request: land #643 (fix #638)
Land #643 (fix #638: Layers panel lays out only rows in view, keeps open rows in the file)
2026-10-09 08:40:24 +07:00
bflatastic 965fc449e1 Merge #643: Fix #638: Layers panel lays out only the rows in view and keeps open rows in the file 2026-10-09 08:31:13 +07:00
bflatasticandClaude Opus 5.5 977e5d1967 Fix #638: Layers panel lays out only the rows in view and keeps open rows in the file
The panel laid out a row (with its thumbnail and several whole-document
lookups) for every open row each frame. A layer of 5,000 groups of 10 paths,
all open, took about 29 s a frame headless. Now the rows shown are listed once
per document change (with their layer colour and dimming worked out on the
way down) and ScrollArea::show_rows lays out only those in view: 0.4 ms a
frame. A row highlighted out of view is scrolled to.

The open rows move from UI state into the document's state
(DocState::layers_open, OpenRows): a document opens with only its top-level
layers open, and native files save the open rows (Document::layers_open,
written only when not the default, at save time like the view, so opening or
closing rows never marks the document modified).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:19:49 +07:00
BFlat b73e19eef2 Merge pull request: land #639 (part of #372)
Land #639 (part of #372: SVG import keeps wrapped layers, round-trips sublayers)
2026-10-09 07:45:18 +07:00
bflatastic 4792bd82a2 Merge #639: Part of #372: SVG import keeps wrapped layers and round-trips sublayers 2026-10-09 07:41:02 +07:00
bflatasticandClaude Opus 5.5 d5e6be1565 Part of #372: SVG import keeps wrapped layers and round-trips sublayers
Top-level groups already opened as layers named from data-name, else their
id. Now that also holds when one more id-less group wraps them (a transform
on all the art, or a <switch>'s chosen group), and hidden objects inside a
<switch> are kept (hidden) instead of dropped.

Nested groups stay groups unless something marks them as layers: the SVG
export now writes data-vc-layer="sublayer" on layers inside layers, and
import makes such a group (or an Inkscape layer group) a sublayer of the
layer holding it, in that layer's colour. A marked group anywhere else
stays a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:36:21 +07:00
BFlat a469568c7d Merge pull request #636 from HEOJUNFO/pixel-preview-pixels
View › Pixel Preview: show the document pixels the art rasterizes to
2026-10-09 06:57:47 +07:00
heojunfoandClaude Opus 5.5 53b4845f6f View › Pixel Preview: show the document pixels the art rasterizes to
Pixel Preview only switched the pixel grid on from 600%: the art was still
rendered for the screen and drawn with linear filtering, so curves and
strokes stayed smooth vectors. Now, while a document pixel is bigger than a
screen pixel, the canvas renders the visible document pixels one per point
(anti-aliased by coverage), uploads them with nearest filtering and places
them over the document rect they cover, turning with a rotated view.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:49:18 +09:00
BFlat 5f92f5eb7f Merge pull request: land #631, #632
Land #631 (ICF character alignment) and #632 (French interface)
2026-10-09 06:34:55 +07:00
bflatasticandClaude Opus 5.5 409af66699 Tests: #631's ICF test builds its runs with #627's inline field
#627 added TextRun::inline while #631 was open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:26:49 +07:00
bflatasticandClaude Opus 5.5 65f35f20a6 French: ICF Top/Right and ICF Bottom/Left (#631 landed beside the catalog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:25:14 +07:00
bflatastic 2b10c15d7a Merge #632: Add French (fr) interface translation
# Conflicts:
#	ROADMAP.md
2026-10-09 06:24:09 +07:00
bflatastic 712f4316e0 Merge #631: Type: Character Alignment on the ICF top and bottom (ideographic character face)
# Conflicts:
#	ROADMAP.md
#	crates/text/src/layout.rs
2026-10-09 06:24:04 +07:00
Simon LeblancandClaude Opus 5.5 0716a0d104 Translate the French labels added on main
Free Transform's widget (Free Transform, Perspective Distort,
Constrain), the Properties panel's artboard editing (Artboard, Exit)
and Type › Insert Inline Symbol, found by the catalog completeness
tests after the rebase.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:19:54 +02:00
BFlat a29563fb92 Merge pull request: land #629
Land #629 (pasted type keeps its layout bounds)
2026-10-09 06:18:57 +07:00
Simon LeblancandClaude Opus 5.5 c640a04612 Translate the French strings drawn through translating helpers
These labels reach the screen through helpers that translate their
argument (dim_label, dropdown, tl!(CONST)), so the tl! literal scan
doesn't see them: the Libraries panel's empty state, Recolor Artwork's
1/2/3 Color Job presets, Spot Colors' CMYK Equivalents, the
Eyedropper's 3 x 3 / 5 x 5 Average, Print's PDF File destination and
Save PDF's CMYK profile in effect output intent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:25 +02:00
Simon LeblancandClaude Opus 5.5 e22d22a4ce Order the French code and docs alphabetically by language code
French now sits between Spanish and Italian (es, fr, it, ru): the
plural rule, the LANGUAGES registry entry (so the Language menu lists
Français before Italiano), the locale tests, the fr() helper and
french_reads_as_french. README, ROADMAP and docs/development.md list
the languages in the same order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:25 +02:00
Simon LeblancandClaude Opus 5.5 9ec940222b Add French (fr) interface translation
Add fr.tsv with the full interface coverage: every menu string, every
tl! literal, the plurals and the status/error messages (@msg).
Registered as complete_menus with the French plural rule (0 and 1 are
singular); every fr-* locale (fr-FR, fr-BE, fr-CA, fr-CH …) resolves
to it. Keeps the product, workspace and perspective preset names in
English like the other complete catalogs.

Tests: french_reads_as_french; fr added to KEEPS_MENU_NAMES and
COMPLETE_MESSAGES; the Latin-script glyph test covers French letters
and the no-break space; locale tests use de-DE as the unsupported case.

Clean-room: written from the meaning of the English labels in the
vocabulary French-speaking users of vector illustration software know
(Plan de travail, Nuancier, Contour, Fond, Tracé, Calque). No other
application's localisation was copied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:14 +02:00
bflatastic 9e2fb37f3a Merge #629: Fix pasted type losing its layout bounds cache
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-09 06:14:31 +07:00
imachusandClaude Opus 5.5 4411688b3d Type: Character Alignment on the ICF top and bottom (ideographic character face)
Character panel menu › Character Alignment gains ICF Top/Right and ICF Bottom/Left, and
`charAlign` takes `icfTop` / `icfBottom`: a character smaller than the largest on its line lines
up its ideographic character face's top (right, vertical) or bottom (left) with the big one's.

`FontFace::icf_margins` places the ICF inside the em box as the OpenType baseline tags define it
(https://learn.microsoft.com/en-us/typography/opentype/spec/baselinetags): `icfb` / `icft` of the
BASE table's horizontal and vertical axes against `ideo` / `idtp` (else the OS/2 typographic
descender and one em), for Han ideographs (else kana, else the first script). Without them, as that
page allows, the average ink box of some ideographs and kana; a face without ideographs uses its
em box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:14:17 +09:00
BFlat 6b2c90f756 Merge pull request: land #627, #628
Land #627 (inline symbols in text) and #628 (comment cleanup)
2026-10-09 06:13:34 +07:00
Diego Gomez Garcia 1ee36c2c07 Fix pasted type losing its layout bounds cache
Type pasted or imported from another app (Illustrator, PDF, SVG) arrives
without cached_bounds, which is not serialized. TextObject::bounds() then
falls back to estimate_bounds(), a rough 0.55 em per character, so the
selection box came up short of the last character and alignment looked off
until the file was saved and reopened (Session::add_document refreshes it).

Refresh the bounds of pasted text in paste_clip, covering paste and paste in
front/back/in place and every clipboard flavour, with a regression test.
2026-10-09 01:05:11 +02:00
bflatastic f365dc1c74 Merge #627: M7: inline symbols in text 2026-10-09 06:02:59 +07:00
bflatastic e9de045f06 Merge #628: Type: comments on new type's burasagari default state the default only 2026-10-09 06:02:58 +07:00
BFlat 58a3a68d83 Merge pull request #625 from DWAA1660/fix/536-popover-swatch-libraries
Fix #536: the Fill/Stroke popover's Swatch Libraries button opens its menu
2026-10-09 06:02:12 +07:00
imachusandClaude Opus 5.5 a366e4ff79 Type: the comments on new type's burasagari default state the default only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:01:16 +09:00
BFlat 8b81d58845 Merge pull request: land #568, #570, #623
Land #568 (fix #563), #570 (fix #530), #623 (part of #579)
2026-10-09 05:57:51 +07:00
Claude d9b5eea18d Fix #536: the Fill/Stroke popover's Swatch Libraries button opens its menu
In the Fill or Stroke chip's popover (Control bar, Properties ›
Appearance), the Swatch Libraries Menu button at the foot of the
swatches closed the popover and showed nothing, as in the video on
#536; Show Swatch Kinds did the same. egui remembers one open popup per
window: the chip's popover was such a remembered popup, so the button's
menu, opening as another one, closed the popover and itself with it.

The popover now keeps its own open state with widgets::popover (made for
popovers that open menus of their own), so the libraries menu opens over
it and a library chosen there opens in the library panel. Escape, a
click outside and a click on the chip still close it.

Test: tests_paintchips::the_popover_swatch_libraries_button_opens_the_libraries_menu
(fails before this change: the popover closes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW
2026-10-08 16:53:57 -06:00
bflatasticandClaude Opus 5.5 8ccafe1187 Russian: Artboard and Exit for the Properties panel's artboard editing
#570 added them while the Russian catalog was landing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:53:30 +07:00
bflatastic f3b90fb93f Merge #623: Part of #579: vectorcraft-cli lists the fonts font services load too
# Conflicts:
#	ROADMAP.md
2026-10-09 05:48:40 +07:00
bflatastic fc052cb001 Merge #570: Fix #530: Edit Artboards adds, deletes and leaves artboards from Properties
# Conflicts:
#	ROADMAP.md
2026-10-09 05:48:30 +07:00
bflatastic 896f8efea8 Merge #568: Fix #563: the font menu's live preview no longer flickers
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/tests_font_menu.rs
2026-10-09 05:48:00 +07:00
BFlat 921e60f4fc Merge pull request: land #621 (fix #585)
Land #621 (fix #585: two-finger tap undoes, three-finger tap redoes)
2026-10-09 05:46:59 +07:00
haven b843fe8af3 M7: inline symbols in text
Document symbols placed inline in a run of type (beyond Illustrator; like
InDesign inline anchored objects): TextRun.inline holds the symbol, scale and
shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on
the cap height. text.insertInline and Type > Insert Inline Symbol; editing
keeps the graphic whole; canvas, SVG (<use>) and PDF draw the art; Create
Outlines turns it into a symbol instance.
2026-10-08 15:45:34 -07:00
bflatasticandClaude Opus 5.5 aa5e8f46e9 Part of #579: vectorcraft-cli lists the fonts font services load too
The DirectWrite font lister (#594) was installed in the desktop app only:
`vectorcraft-cli` (convert, run, MCP) didn't see fonts that font services
such as Adobe Fonts load on Windows, so its exports set such type in a
fallback font.

- The CLI shares the app's `system_fonts.rs` and installs it first thing
  on Windows (not the win7 build). The text crate stays free of unsafe
  code; the CLI denies unsafe code instead of forbidding it, so the
  shared module's own allowance applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:43:13 +07:00
bflatastic 9dec49ed03 Merge #621: Fix #585: a two-finger tap undoes, a three-finger tap redoes
# Conflicts:
#	ROADMAP.md
2026-10-09 05:42:35 +07:00
BFlat dc157c6c5b Merge pull request: land #619 (fix #598)
Land #619 (fix #598: the Free Transform widget)
2026-10-09 05:41:12 +07:00
bflatasticandClaude Opus 5.5 aa125bfff1 Fix #585: a two-finger tap undoes, a three-finger tap redoes
Preferences › Devices › Enable Touch Gestures (on by default) was stored
but nothing read it: no touch gesture was implemented.

- `touch::Taps` follows the fingers egui reports (winit reads them from
  the system: WM_POINTER on Windows): fingers down together and lifted
  within half a second, none moved more than 16 points, is a tap. Two
  fingers undo, three redo, with the preference on.
- egui makes the first finger the pointer too, so it has pressed on the
  canvas by the time a second lands: that press is dropped then (its tool
  interaction rolled back), and the rest of the gesture presses nothing.
  The tap so undoes what came before it, not the first finger's mark.
- A pen is one contact, so pen strokes and taps are unaffected; pinch zoom
  is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:39:47 +07:00
bflatastic 22008b4e68 Merge #619: Fix #598: the Free Transform widget
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-09 05:36:53 +07:00
bflatasticandClaude Opus 5.5 2497713779 Fix #598: the Free Transform widget
While the Free Transform tool (E) is active, a small box at the canvas's
top left holds Constrain over the tool's three modes: Free Transform,
Perspective Distort and Free Distort. Before, the modes were reachable
only by modifier keys or `tool.setOption`.

- The buttons set the tool's `constrain` and `mode` options through
  `tool.setOption`, as agents do. Constrain acts as Shift held and is
  greyed in the distort modes, where it does nothing. The modifier keys
  keep working while dragging.
- Theme tokens; Constrain shows the link icon, Free Transform the tool's
  own icon, the distort modes are drawn in code. Tooltips translated
  (cs, es, it, ja, pt-br, ru, zh-hans, zh-hant).
- `ui.inspect` reports where it shows (`freeTransformWidget`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:31:43 +07:00
BFlat e037064b2d Merge pull request: land #616 (fix #597)
Land #616 (fix #597: a browser-copied image pastes as the picture)
2026-10-09 05:31:24 +07:00
bflatastic 564a3ae662 Merge #616: Fix #597: an image copied in a browser pastes as the picture, not its address
# Conflicts:
#	ROADMAP.md
2026-10-09 05:26:43 +07:00
BFlat 106abe639f Merge pull request: land #612, #610, #611
Land #612 (fix #593), #610 (Shaper groups), #611 (per-paragraph attributes)
2026-10-09 05:25:17 +07:00
bflatasticandClaude Opus 5.5 54eed6ec4c Fix #597: an image copied in a browser pastes as the picture, not its address
A browser's Copy Image puts the picture on the clipboard with the image's
address as text (and HTML). Paste takes text before bitmaps, as word
processors offer a picture of copied text, so it pasted the address.

- `clipboard::is_address`: text that is only an address (http, https,
  file, ftp, data or blob).
- Desktop paste (Windows, macOS, Linux): such text gives way to the
  clipboard's bitmap when it has one. An address alone pastes as text, as
  before; nothing is fetched.
- Web: a paste whose text is only an address takes its picture file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:22:09 +07:00
dotCooCoo 67af21c6db Contributors: add dotCooCoo (Robert Lee) 2026-10-08 15:18:57 -07:00
dotCooCoo e0b1f55250 Swatches: swatch exchange (.ase) files open as swatch libraries
File › Open, Other Library… and the User Defined library folder read
.ase files. Colors keep their model, global and spot colors stay global
and spot, and color groups stay groups. The library readers take a
file's bytes. On the web, File › Open passes a swatch library file to
swatch.library.load as dataBase64 instead of as lossy UTF-8 text.

Swatch and group names in a library are now unique, in .gpl files too.
Add to Swatches puts each color group of a library into one document
group when the document already has a swatch with the group's name.
The library fuzz helpers fail when a command panics.
2026-10-08 15:18:57 -07:00
bflatastic 8d5b52965c Merge #611: M7: per-paragraph paragraph attributes 2026-10-09 05:13:59 +07:00
bflatastic 33f38cef08 Merge #610: Fix Shaper recognition and add editable Shaper groups
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-09 05:13:59 +07:00
bflatastic 8a18a74daf Merge #612: Fix #593: anchors and handle ends are easier to grab than the segments through them
# Conflicts:
#	ROADMAP.md
2026-10-09 05:13:43 +07:00
bflatasticandClaude Opus 5.5 1a1119b1d4 Tests: Arabic joining tests pick an OpenType (GSUB) font
macOS's Geeza Pro is an AAT font: its morx shaping takes no context from
outside the run, so different_style_arabic_boundary_receives_joining_context
failed on Macs (reported in #610). The tests now use the first candidate
with a GSUB table and skip when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:13:15 +07:00
BFlat 79a67e2642 Merge pull request: land #608 (fix #592)
Land #608 (fix #592: Linux file dialogs no longer stop the window answering)
2026-10-09 05:11:15 +07:00
bflatasticandClaude Opus 5.5 a0d5d72d90 Fix #593: anchors and handle ends are easier to grab than the segments through them
An anchor was picked within the selection tolerance (3 px), the same reach
as its segments: a press a little off the anchor's 5 px square took the
curve instead.

- `ToolContext::point_tol`: anchors and direction handle ends are picked
  within the selection tolerance, but at least 2 px past the drawn point
  (4.5 px at the default Size 3, more with a larger Size). Anchors are
  tested before segments, so where both are within reach the anchor wins.
- Direct Selection (presses, Highlight anchors on mouse over, blend spine
  points and handles), the Anchor Point, Delete Anchor Point and Scissors
  tools, and the Pen (its Alt conversions; its close/continue reach stays
  5 px unless the points are drawn larger) use it. Segments, objects and
  frame edges keep the selection tolerance.
- Of the topmost path with anchors in reach, the nearest anchor is picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:09:31 +07:00
haven f7761bf5bc M7: per-paragraph paragraph attributes
Each paragraph has its own alignment, indents, spacing, hyphenation, tab
stops and paragraph style (TextObject.paras; para stays paragraph 0's, so
old files and readers work). Layout, editing (split/merge), threads, the
Paragraph/Tabs/Paragraph Styles panels, ranged text.setStyle/setFormat/
tabs/paraStyle.apply, SVG export/import and the fuzzers follow.
2026-10-08 15:07:13 -07:00
bflatastic d9a2cfe7f9 Merge #608: Fix #592: file dialogs on Linux no longer stop the window answering
# Conflicts:
#	ROADMAP.md
2026-10-09 05:03:38 +07:00
David Whiting dc6f138a72 M2.9: translate Shaper commands in Russian 2026-10-08 23:03:34 +01:00
BFlat 6def232852 Merge pull request: land #606 (area type fit)
Land #606: area type Auto Size and Shrink Text to Fit
2026-10-09 05:03:02 +07:00
bflatasticandClaude Opus 5.5 0df8231334 Tests: format round trips find their objects by content, not by count
#606 adds a second area type (Shrink Text to Fit) to the rich fixture: the
vertical-alignment test now picks the centred area type by its text and
strips verticalAlign from every area for its old-file case, and the
composer test checks the Single-line text and the rest at the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:00:01 +07:00
bflatasticandClaude Opus 5.5 37d4477362 Fix #592: file dialogs on Linux no longer stop the window answering
VectorCraft asks for a path where it needs one and goes on with it at
once, so its file dialogs ran on the UI thread. Windows and macOS dialogs
run the window's events meanwhile; on Linux nothing answered the Wayland
compositor's pings while a dialog was open, and Hyprland offered to kill
the window as not responding.

- ui-egui `picks`: every file dialog (open, save, folder, place) goes
  through one place. With the host's `start_pick` (desktop Linux) the
  dialog shows on a thread of its own; what asked (the command, the
  dialog's OK, a panel's or dialog's button) gives up for now and runs
  again with the picked path when the dialog answers. Another dialog
  waits for the open one.
- The desktop app passes its window as the dialogs' parent on Linux (the
  portal can then associate its window, "Unhandled parent window type"
  before) and Windows; macOS shows them as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:59:05 +07:00
David Whiting 7e82dbc05b M2.9: add editable Shaper groups and shape orientation steps 2026-10-08 22:52:23 +01:00
David Whiting cfe8eaafaf M2.9: recognize uneven Shaper squares independently of stroke start 2026-10-08 22:52:23 +01:00
bflatastic 611deeda46 Merge #606: M7: area type auto size and shrink text to fit 2026-10-09 04:52:14 +07:00
BFlat c3b73f0146 Merge pull request: land #603 (part of #372)
Land #603 (part of #372: .ai layers and sublayers as PDF layers, hidden ones kept)
2026-10-09 04:51:50 +07:00
haven 674aef95b0 M7: area type auto size and shrink text to fit
Area Type Options gain a fit: none, autoHeight (Illustrator's Auto Size:
the frame's height follows the text after every edit, resolved in
refresh_bounds inside the edit so it shares the edit's undo step) and
shrinkText {minPercent} (beyond Illustrator: overflowing text scales its
sizes, leading and baseline shifts down at layout time by the largest
factor, in 0.1 pt steps of the first run, that fits). The Auto Size New
Area Type preference now applies. text.areaOptions and document.inspect
report overflow and the shrink factor; the Area Type Options dialog
offers fit and first baseline as dropdowns.
2026-10-08 14:48:25 -07:00
bflatastic ef99ab8d1c Merge #603: Part of #372: .ai files write layers and sublayers as PDF layers, hidden ones kept
# Conflicts:
#	ROADMAP.md
2026-10-09 04:46:09 +07:00
BFlat a89f914119 Merge pull request: land #600, #601
Land #600 (area type vertical alignment) and #601 (RTL shaping across runs)
2026-10-09 04:45:23 +07:00
bflatasticandClaude Opus 5.5 50bb36ebc2 Part of #372: .ai files write layers and sublayers as PDF layers, hidden ones kept
A `.ai` file's PDF part is what apps that don't read VectorCraft's native
document open. It had no PDF layers: hidden layers and sublayers were left
out of it and everything else was flat page content.

- `.ai` files (Save As, exports, the CLI) write PDF layers unless
  `createLayers` is false (a PDF standard still decides for itself).
- createLayers writes sublayers too, as optional content groups listed
  under their layer's in `/Order`, hidden ones off with their art.
- A layer's form gets a box round the whole page instead of its art's
  bounds, which readers made a clipping group of, gathering a layer's art
  and its sublayers' in one group.
- PDF import: art inside a group, clip or form that dissolves into its
  parent keeps the layer it was drawn in, so sublayers nested in their
  layer's content import as sublayers.
- Save PDF: "Create PDF layers from layers and sublayers" (ja, es, it,
  pt-br, ru, zh-hans, zh-hant).

Hidden objects (not layers) still aren't in the PDF part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:40:30 +07:00
bflatastic 4e774b8b61 Merge #601: Fix RTL shaping across text runs 2026-10-09 04:39:56 +07:00
bflatasticandClaude Opus 5.5 017c846889 Tests: the top-to-top em box check needs the craft-fonts CJK faces
Without craft-fonts the CJK text falls back to a system font with its own
em box (Windows: 34.0 instead of 35.2 at 40 pt); the relative alignment
checks hold either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:39:56 +07:00
bflatasticandClaude Opus 5.5 e12ed1608e Tests: the composer round trip counts #600's area type in the rich fixture
#587 and #600 landed together: the fixture's new area type (Every-line,
the default) is a second text object the composer test now sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:36:06 +07:00
Emad Fussi 9e6c115481 Fix RTL shaping across text runs 2026-10-09 00:34:00 +03:00
bflatastic c7b2bba90e Merge #600: M7: area type vertical alignment 2026-10-09 04:31:10 +07:00
haven d2bb95daab M7: area type vertical alignment
Area Type Options > Align: Top / Center / Bottom / Justify (AreaOptions.verticalAlign,
LayoutOptions.vertical_align). Each row/column cell is aligned on its own; rectangular
cells shift their lines, other frames and text wrap reflow until settled without
losing text. Justify shares the leftover space equally between line gaps. Area Type Options
shows Align and First Baseline as dropdowns (the parameter dialog's choices table).
2026-10-08 14:28:02 -07:00
BFlat 8d0cede84a Merge pull request: land #594 (fix #579)
Land #594 (fix #579: fonts from font services such as Adobe Fonts on Windows)
2026-10-09 04:26:21 +07:00
bflatastic 030d407f61 Merge #594: Fix #579: fonts activated by font services such as Adobe Fonts are listed on Windows
# Conflicts:
#	ROADMAP.md
2026-10-09 04:18:54 +07:00
BFlat 465ec07445 Merge pull request: land #591 (fix #578)
Land #591 (fix #578: no flicker dragging the Color spectrum near white)
2026-10-09 04:17:20 +07:00
bflatasticandClaude Opus 5.5 5854346053 Fix #579: fonts activated by font services such as Adobe Fonts are listed on Windows
Font services load their fonts with GDI at run time, from files in their
own folders, without installing them: neither the font folders nor the
registry list them, so the font scan never saw them. DirectWrite's system
font collection holds them since Windows 10 1607.

- vectorcraft-text: `set_platform_font_files` lets the app name font files
  the platform knows; each scan and each check for installed fonts asks
  again and reads those outside the font folders. A file named by itself
  is read whatever its extension (font services keep fonts in files
  without one).
- The desktop app lists the local files of DirectWrite's system font
  collection on Windows (`system_fonts.rs`, through the windows crate
  already in the build), read in place.
- The check for fonts installed while the app was in the background runs
  on a thread: DirectWrite's check for updates takes about 50 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:16:14 +07:00
bflatastic f4b4312cc9 Merge #591: Fix #578: a drag on the Color panel's spectrum no longer flickers near white
# Conflicts:
#	ROADMAP.md
2026-10-09 04:13:07 +07:00
BFlat 0869f51360 Merge pull request #587 from Haven-King/feat/text-engine/text-composer-ragged
M7: Every-line Composer for ragged text
2026-10-09 04:12:08 +07:00
haven d2aad22dac M7: Every-line Composer for ragged text
Illustrator's Every-line Composer balances the breaks of the whole
paragraph for left/centre/right aligned text too, not only justified
text. VectorCraft only composed justified area text; ragged text was
always broken greedily.

- Model: ParaStyle.composer (vectorcraft_doc::Composer, re-exported by
  vectorcraft-text), Every-line by default like Illustrator, saved only
  when Single-line. layout() reads it from the object;
  LayoutOptions.composer is now an Option override for tests.
- Composer: the justified gate is gone. Ragged lines never shrink (a
  line that doesn't fit ends the node), badness is 100*r^3 with r = the
  line's leftover space over a rag zone of 1/6 of the width (finite for
  lines without spaces); the last line is free unless shorter than 1/3
  of the width (measured in zones of 1/3), so a lone short word on the
  last line pulls a word down. Hyphens cost 25 in ragged text (50 in
  justified). Knuth-Plass fitness classes with adjacency demerits
  (justified 10000, ragged 1000). Lines past the last width change
  share one node key, active nodes are capped (4096) and paragraphs
  over 200k glyphs break single-line, so work stays bounded; ragged
  tries a tolerance of one rag zone, then any fitting breaks, then
  falls back to greedy.
- text.setFormat {composer: "singleLine"|"everyLine"}; the Paragraph
  panel menu's composer items work with a check mark (Reset Panel goes
  back to Every-line).
- Tests: ragged Every-line moves "or" down in "Destroy all creatures
  with toughness X or less." where greedy leaves "less." alone; the rag
  of long paragraphs is no worse than greedy (sum of squared leftover
  and of neighbour differences); Single-line is greedy; the 10k-char
  layout budget covers ragged text; command test with undo; native
  format round trip (rich_session's text is now Single-line).
  No existing test needed changing: the justified tests and
  hyphenated_layout_adds_hyphens (ragged, now Every-line) still pass.

Auto alignment (start of the paragraph direction) is ragged; a wrapped
line's Mojikumi flush opening bracket gives its half em back to the
composer, as in the line loop; tests cover Auto in both directions.

Layout of 10k chars, release: justified Every-line 5.6 ms, ragged
Every-line 5.8 ms, ragged Single-line 4.8 ms.
2026-10-08 14:03:33 -07:00
bflatasticandClaude Opus 5.5 2e64786e8c Fix #578: a drag on the Color panel's spectrum no longer flickers near white
The Color panel shows the out-of-gamut and out-of-web warnings under its
proxy only when the colour needs them, so the panel grew and shrank with
the colour. Dragging on the spectrum's pale top edge, a colour out of
gamut added a warning, the spectrum moved down under the pointer, the
pointer then picked white (above the spectrum), the warning went away,
the spectrum moved back up: the colour flipped every frame.

The panel now keeps room for the warnings its mode can show, so its
height no longer depends on the colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:02:44 +07:00
BFlat 367117ba26 Merge pull request: land #583, #584 (fix #577)
Land #583, #584 (fix #577) with a ligature-threshold follow-up
2026-10-09 04:02:24 +07:00
bflatasticandClaude Opus 5.5 5a09d1aef8 Type: ligatures drop past -20 or +55 tracking, as Adobe's composers do
#583 kept ligatures within +-50/1000 em. Adobe's composers are described
as dropping them past about -8% and +22% of the word space (John Hudson,
www-style, 2012-07): about -20 and +55 for a typical 250/1000 em space.
Condensed type shows a fixed ligature sooner than expanded type does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:56:10 +07:00
bflatastic e1d560a727 Merge #584: Fix #577: the gradient stop popover stays open after the click that opens it
# Conflicts:
#	ROADMAP.md
2026-10-09 03:55:34 +07:00
bflatastic 07654ff45d Merge #583: M7: keep ligatures under small tracking 2026-10-09 03:55:19 +07:00
BFlat 53297259d2 Merge pull request: land #581 (Russian translation)
Land #581: Russian (ru) interface translation
2026-10-09 03:54:27 +07:00
bflatasticandClaude Opus 5.5 b771a74361 Russian: H is В (высота), not Ш
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:52:15 +07:00
bflatasticandClaude Opus 5.5 ba93f62bcb Fix #577: the gradient stop popover stays open after the click that opens it
The Gradient panel's colour chip opens the stop popover on a click. The
popover closes on a click elsewhere, and the opening click itself, which
is released in the frame the popover first draws and lies outside it,
counted as one: the popover closed at once (a double-click got through,
as the popover lets the double-click that opens it from the canvas pass).

The popover now records the frame it first drew in and only a click in a
later frame closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:48:27 +07:00
bflatastic 04827da657 Merge #581: Add Russian (ru) interface translation 2026-10-09 03:48:19 +07:00
BFlat d066e0ddc0 Merge pull request: land #580 (fix #522)
Land #580 (fix #522: SVG round trips keep hidden objects and data-* attributes)
2026-10-09 03:47:30 +07:00
MaksPV 89b10cce52 Add Russian (ru) interface translation
Add ru.tsv with the full interface coverage: every menu string, every
tl! literal, the plurals (one/few/many) and the status/error messages
(@msg). Registered as complete_menus with the Russian plural rule;
every ru-* locale resolves to it. Keeps the product, workspace and
perspective preset names in English like the other complete catalogs.

Tests: russian_reads_as_russian, russian_plurals_have_three_forms,
russian_glyphs_are_available_without_system_fonts; ru added to
KEEPS_MENU_NAMES and COMPLETE_MESSAGES.

Clean-room: written from the meaning of the English labels in the
vocabulary Russian-speaking users of vector illustration software know
(Слой, Образцы, Обработка контуров, Монтажная область, Обводка,
Заливка), matching the Russian catalog of the sibling app PhotoCraft.
No other application's localisation was copied.
2026-10-08 23:44:45 +03:00
bflatastic c7094320b3 Merge #580: Fix #522: SVG round trips keep hidden objects and data-* attributes
# Conflicts:
#	ROADMAP.md
2026-10-09 03:41:22 +07:00
bflatasticandClaude Opus 5.5 2a35c4a82f Fix #522: SVG round trips keep hidden objects and data-* attributes
An SVG used as an animation rig lost its hidden alternates and their
rotation pivots: the exporter left hidden objects out (only hidden layers
could be kept), and the importer dropped data-* attributes.

- SVG export: hiddenLayers (on by default for document.save, as before)
  now keeps hidden objects too, each written display="none" on its own
  element (path, image, text or group). Exports still leave them out
  unless asked; the CLI's convert asks, as a conversion keeps the
  document.
- An object's own data: ObjectAttributes.data, the data-* attributes in
  order (ours, data-name and data-vc-*, aside). The importer keeps them on
  the objects and layers they belong to (at most 64 per element), the
  exporter writes them back, the native format saves them (missing in
  older files), and object.setProps {data: {key: value|null}} sets or
  removes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:36:08 +07:00
BFlat 28846a98a0 Merge pull request: land #574 (fix #526)
Land #574 (fix #526: the exports and the CLI write .ai as Save As does)
2026-10-09 03:31:37 +07:00
bflatastic 4051a895d7 Merge #574: Fix #526: the exports and the CLI write .ai as Save As does
# Conflicts:
#	ROADMAP.md
2026-10-09 03:26:21 +07:00
bflatasticandClaude Opus 5.5 1e2cb082ba Fix #526: the exports and the CLI write .ai as Save As does
file.saveAs and document.save wrote a PDF-compatible .ai, but
document.export (and with it the CLI's run --export x.ai and convert
in.svg out.ai, and MCP's export) refused it as a format that can only be
opened, and --help didn't list it as writable.

The .ai encoder is now shared: save.rs's ai_params and encode_ai (a PDF
of every artboard with the PDF options, carrying the native document,
blank pages with pdfCompatible: false) serve Save As and the exports
alike. The .ai format is writable, so document.formats, --help and the
MCP export tool list it; an export leaves the document's own path alone.
Export As and Export for Screens keep leaving .ai to Save As.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:18:07 +07:00
BFlat 207bd09635 Merge pull request #572 from storytold/fix/issue-569
Fix #569: Shape Builder merges leave no stray edges in their outlines
2026-10-09 03:12:05 +07:00
bflatasticandClaude Opus 5.5 2023a47188 Fix #569: Shape Builder merges leave no stray edges in their outlines
Merging regions united the faces' outlines with a boolean union. Each
face had been refit on its own, so two faces' shared edge could come out
a hair apart: the union then kept the edge between them, or a spur of
it, and a stroked merged shape (unfilled art) showed a stray line inside.
Subtracting the merged shape from the others left slivers along it too.

pathops::FaceMerger merges faces from the planar arrangement itself,
made once per merge: their contours share their edges exactly, so the
edges between merged faces vanish. The Shape Builder builds the merged
shape that way, and what is left of each shape from its faces not merged
(rather than subtracting the merged shape from it). Selections with open
paths keep the old union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:04:48 +07:00
BFlat 85319c972c Merge pull request #571 from mo0kid/fix/macos-native-menu-dismissal
Fix macOS native menu dismissal
2026-10-09 03:00:47 +07:00
haven 0d68c1283a M7: keep ligatures under small tracking
Only real letterspacing (|tracking| > LIGATURE_TRACKING_LIMIT = 50/1000 em)
drops standard ligatures now, and an explicit liga/clig in the character's
features always wins. The OpenType panel's hint follows the same rule.
2026-10-08 12:55:39 -07:00
David Whiting 94ecbec9fc M14.8: opt in to public GitHub profile-name credit 2026-10-08 20:50:54 +01:00
David Whiting cb61bdf61f M14.8: dismiss macOS native menus after selection or outside input 2026-10-08 20:50:54 +01:00
David Whiting d1d78e61da M0.CI: restore checks without optional fonts 2026-10-08 20:50:54 +01:00
bflatasticandClaude Opus 5.5 1d013959cb Fix #530: Edit Artboards adds, deletes and leaves artboards from Properties
The Properties panel's Edit Artboards turned on the Artboard tool but the
panel went on showing the document's sections, with no way there to add
an artboard (only dragging on the canvas, which the hint bar names, or
the Artboards panel's button). While the Artboard tool is on, the panel
now shows the active artboard: its name and X, Y, W and H (editable,
artboard.setProps), New Artboard (which becomes the active one), Delete
Artboard (with more than one), and Exit back to the Selection tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:29:26 +07:00
BFlat c26c4a2060 Merge pull request #564 from Haven-King/feat/vectorcraft-placed-documents
Feat/vectorcraft placed documents
2026-10-09 02:26:56 +07:00
haven 5e7af3f83c Placed documents: Place's Link tooltip, Break Link in the Links panel 2026-10-08 12:20:59 -07:00
haven 2418921760 Placed documents: on screen, cached bitmaps made in the background 2026-10-08 12:20:51 -07:00
haven e04b94938b Placed documents: Links commands check, update, relink and break their links 2026-10-08 12:20:51 -07:00
haven afc45cdeb8 Placed documents: File › Place links a VectorCraft document by default; output reads its file 2026-10-08 12:20:51 -07:00
haven 6d56195eda Placed documents: the object, drawn exactly and kept as art by every exporter 2026-10-08 12:20:51 -07:00
haven 02072c310a Format v4: large data after the JSON 2026-10-08 12:20:51 -07:00
haven 92817d6ca1 Widgets: check_tip, a checkbox whose tooltip shows over its box and label 2026-10-08 12:20:51 -07:00
bflatasticandClaude Opus 5.5 b1f7d5ad7d Fix #563: the font menu's live preview no longer flickers
Hovering a family in a font menu previews it on the selected text. The
menu took its caller's font as the current family, and the caller reads
it from the text, which the preview had just changed: the next frame the
hovered family looked like the current one, so the menu ended the
preview, and the frame after it previewed the family again. The text
switched fonts every frame (and the document's revision ran up with it).

The menu now keeps the family the text had as it opened as the current
one: a preview stays on the text until another row is hovered, the
current family is hovered again, or the menu closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:20:21 +07:00
BFlat 857273001b Merge pull request: land #565 (fix #541)
Land #565 (fix #541: a click on a selected object makes it the key object)
2026-10-09 02:16:52 +07:00
bflatastic 48f07958a9 Merge #565: Fix #541: a click on a selected object makes it the key object
# Conflicts:
#	ROADMAP.md
2026-10-09 02:11:01 +07:00
BFlat c6bea61ffc Merge pull request: land #562 (fix #555)
Land #562 (fix #555: the font menu scrolls only its list)
2026-10-09 02:09:49 +07:00
bflatasticandClaude Opus 5.5 f31d6874aa Fix #541: a click on a selected object makes it the key object
With the Selection tool, a click (no drag, no modifier) on one object of a
selection of several makes it the key object (select.key); a click on the
key again lets it go. The key object is drawn with a thicker outline,
Align aligns to it (the Align panel shows Align to Key Object while there
is a key, and choosing Selection or Artboard lets it go), and
object.align without `to` aligns to the key when there is one, so the
Control bar's and the Properties panel's Align buttons do too. With a
spacing, Distribute Spacing keeps the key object still and spaces the
others from it, and it reports too few objects instead of indexing an
empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:08:56 +07:00
bflatastic c44572d272 Merge #562: Fix #555: the font menu scrolls only its list
# Conflicts:
#	ROADMAP.md
2026-10-09 02:05:49 +07:00
bflatasticandClaude Opus 5.5 1b43d8b8f9 Fix #555: the font menu scrolls only its list
egui's combo box puts its own scroll area around a combo's list, so the
font menu, which scrolls its families itself, had two scrollbars: once
the families reached their end the wheel scrolled the outer one, taking
the search field and the filters out of view.

The font menu (widgets::combo with searchable) now opens its list in a
popup of its own, drawn like the combo box, without that outer scroll
area: below the button, or above it when there is more room there. The
families list fills the popup's room under the search field and filters,
which stay at the top while the list scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:54:11 +07:00
BFlat 2b321d240f Merge pull request: land #558 (fix #550)
Land #558 (fix #550: an SVG of chosen artboards holds only their art)
2026-10-09 01:50:20 +07:00
bflatastic 2139c4d661 Merge #558: Fix #550: an SVG of chosen artboards holds only their art
# Conflicts:
#	ROADMAP.md
2026-10-09 01:45:04 +07:00
BFlat 179a87d0a6 Merge pull request: land #557 (fix #525)
Land #557 (fix #525: Image Trace of a large photo no longer crashes)
2026-10-09 01:42:04 +07:00
bflatasticandClaude Opus 5.5 b832599a47 Fix #550: an SVG of chosen artboards holds only their art
Exporting one artboard of many to SVG wrote the whole document's art into
that artboard's file, the other artboards' art outside its viewBox: an
editor opening it showed every artboard's art. A file for an artboard
named in the export (Use Artboards with a range or All, Export for
Screens) now holds the objects over that artboard only, at any depth of
layers, as DXF's crop leaves them; objects reaching into it stay whole
and a clipping layer keeps all of its objects. An SVG with no artboard
named (Save As) still holds the whole document.

Part of #546: Export As with a range writes the chosen artboard alone in
SVG, DXF, PDF and PNG, each file with that artboard's art (tested from
the dialogs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:38:38 +07:00
bflatastic d7b4d85d3c Merge #557: Fix #525: Image Trace of a large photo no longer crashes the app
# Conflicts:
#	ROADMAP.md
2026-10-09 01:35:31 +07:00
BFlat af7a5239be Merge pull request #556: Release: VectorCraft v0.7.0
Release: VectorCraft v0.7.0
2026-10-09 01:18:16 +07:00
bflatasticandClaude Opus 5.5 06242cd3ab Fix #525: Image Trace of a large photo no longer crashes the app
Tracing a 2048 x 2048 photo with High Fidelity Photo made 733,000 paths
and 7.8 million anchors. The selected result's outlines and anchor
squares then made an egui mesh of 1.8 GB, over the GPU's largest buffer:
wgpu panicked while painting and the app quit (or ran out of memory
first, at 6.4 GB).

- Canvas: a selection with more than 100,000 anchors shows its bounds
  instead of every outline and anchor, and the hover highlight skips
  such an object. The Contextual Task Bar and that outline read the
  selection's bounds from a per-revision cache (VectorcraftApp::
  selection_bounds): a selected trace drew a frame in 20 ms, not 170.
- Image Trace: the colour layers are traced in parallel (26 s to 1.9 s
  for that photo; the web traces them in turn). trace_within stops once
  the paths pass an anchor budget; imageTrace.make allows two million
  and otherwise reports the trace as too complex, with what to change.
  Images over 64 megapixels are refused from their header, before they
  are decoded.
- The trace crate's messages are translated (es, it) and scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:18:03 +07:00
bflatasticandClaude Opus 5.5 c3279f1bcf Release: VectorCraft v0.7.0
For users, since 0.6.0:
- Linux: no more startup crash on desktops whose compositor runs on another
  GPU (#502, #551); the window renders on the desktop's GPU and retries
  the next one instead of crashing.
- Windows: rendering through DirectX 12 first, fixing the black flicker on
  Intel GPUs (#545).
- Italian interface (#514).
- macOS: the menus live in the system menu bar.
- Panels, panel groups and the Tools panel undock into floating panels (#495).
- Live Corners on any path's corners: stars, polygons, pen paths (#511).
- Drawing tools snap to Smart Guides as the Selection tool does (#506).
- Pen: Cmd/Ctrl edits the path being drawn, Alt reshapes segments, drags
  out of the first anchor always close (#494, #501); Convert buttons for
  whole paths (#504).
- Right-click a ruler to change the document's units; the Control bar's
  Transform link opens the Transform panel.
- Object > Image Trace > Make traces with the Default preset (#543).
- Edit menu Select All, Cut, Copy and Paste act on a focused field (#516).
- Japanese UI: new type starts top-to-top with em box centre alignment (#432).
- PDF/.ai import keeps type strokes, wrapped lines and layer order (#508);
  EPS import fixes (#498, #505).
- macOS 27: only file URLs are opened (#433); Linux: the Paste menu no
  longer freezes on an unresponsive clipboard owner (#510).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:16:08 +07:00
BFlat 4f07bd93b0 Merge pull request #553: land #533, #548, #549, #552
Land #533, #548, #549, #552 with review fixes
2026-10-09 01:08:30 +07:00
bflatastic 086d6287be Merge #552: Fix #543: Object › Image Trace › Make traces with the Default preset
# Conflicts:
#	ROADMAP.md
2026-10-09 01:03:27 +07:00
bflatasticandClaude Opus 5.5 0e6a47e72d Control bar: Italian for the Transform link's tooltip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:03:03 +07:00
bflatasticandClaude Opus 5.5 f8004250fb Fix #543: Object › Image Trace › Make traces with the Default preset
Make… and Make and Expand… opened the generic parameter form, whose
preset was a text box holding a preset's name (Black and White Logo,
6 Colors), so the other presets looked missing. As in Illustrator, Make
and Make and Expand now trace at once with the Default preset, as the
Control bar's Image Trace button does; the button's arrow and the Image
Trace panel's Preset list choose among the 12 presets. The catalogs drop
the two "…" labels; Czech gets Make and Expand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:01:02 +07:00
bflatastic 03288f34dc Merge #549: Fix #545: Windows renders through DX12 before Vulkan
# Conflicts:
#	ROADMAP.md
2026-10-09 00:58:47 +07:00
bflatastic aa4fa48751 Merge #548: Part of #516: the in-window menus' Select All, Cut, Copy and Paste act on a focused field
# Conflicts:
#	ROADMAP.md
2026-10-09 00:58:46 +07:00
bflatastic 165db95dd4 Merge #533: Control bar: Transform link opens the Transform panel; reference point locator redrawn 2026-10-09 00:58:40 +07:00
bflatasticandClaude Opus 5.5 ac043129c8 Fix #545: Windows renders through DX12 before Vulkan
Windows lists each GPU under both backends, and Vulkan came first. On an
Intel GPU its driver made the whole window flicker black while the
pointer moved; the same machine was fine with WGPU_BACKEND=dx12 or gl.
adapter_order now ranks a GPU's DX12 adapter before its Vulkan one, after
the power preference, so the GPU chosen doesn't change, only how it is
driven. Vulkan stays the fallback (a failed DX12 start restarts on it)
and WGPU_BACKEND=vulkan still picks it. Elsewhere there is no DX12
adapter, so nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:56:49 +07:00
bflatasticandClaude Opus 5.5 8e753d746e Part of #516: the in-window menus' Select All, Cut, Copy and Paste act on a focused field
The clipboard keys already went to a focused text field, but on Windows,
Linux and the web the press that opens a menu takes the keyboard from the
field, so Edit > Copy (and Cut, Paste, Select > All) acted on the art.
The menu bar now keeps the field that had the keyboard, and its
selection, while the menus are open; those four commands go to it as the
events their keys send, the frame after the click that chose them, and
give it the keyboard back. Everything else, and those commands with no
field, run as before. The macOS menu bar shares the same helpers.

Tests: Copy, Cut and Paste events in the W field and the Color panel's
hex field edit the text, never the art; Select > All, Edit > Copy and
Edit > Paste clicked in the in-window menus act on the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:51:02 +07:00
BFlat 85dcb28e41 Merge pull request #547: land #542, #523, #528, #527, #538, #529
Land #542, #523, #528, #527, #538, #529 with review fixes
2026-10-09 00:48:38 +07:00
bflatastic 971b0bb95e Merge #529: Fix #506: drawing tools snap to Smart Guides as the Selection tool does
# Conflicts:
#	ROADMAP.md
2026-10-09 00:44:46 +07:00
bflatasticandClaude Opus 5.5 01c9be7d35 Part of #511: Italian rows for the Live Corners messages on any path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:44:29 +07:00
bflatastic 7248c6aeec Merge #538: Fix #511: Live Corners on any path's corners (stars, polygons, pen paths)
# Conflicts:
#	ROADMAP.md
2026-10-09 00:44:29 +07:00
bflatasticandClaude Opus 5.5 6efe9df3e8 Type (#432): the language test keeps its frames in English
The drawing language is process-wide, so a Japanese frame translated the
tests running beside it (Appearance, Attributes, menus). The test now
checks that each frame hands the engine its language without leaving
English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:44:10 +07:00
bflatastic 7a270c8deb Merge #527: Type (#432): new type in a Japanese interface starts top-to-top with em box centre alignment
# Conflicts:
#	ROADMAP.md
2026-10-09 00:44:10 +07:00
bflatastic 5d695dcb80 Merge #523: Fix #433: open only file URLs handed to the app (macOS 27) 2026-10-09 00:43:58 +07:00
bflatastic cb6e996243 Merge #542: Clippy: use as_chunks for constant-size chunks (Rust 1.98) 2026-10-09 00:43:57 +07:00
bflatasticandClaude Opus 5.5 d9544d8f87 UI: the ruler unit menu in the ROADMAP; its test names the PR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:43:50 +07:00
bflatastic e28d9222e8 Merge #528: UI: right-click a ruler to swap the document units 2026-10-09 00:43:49 +07:00
Michael GiesonandClaude Opus 5.5 c21f783286 Clippy: use as_chunks for constant-size chunks (Rust 1.98)
Rust 1.98's clippy adds `chunks_exact_to_as_chunks`, which `-D warnings`
turns into an error, so `cargo xtask ci` fails on main with the current
stable toolchain. The EPS stencil-mask loop and the cursor rasterizer
(and two cursor tests) now use `as_chunks`, which yields fixed-size
arrays; like `chunks_exact`, it skips an incomplete last chunk, so
nothing changes in behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:57:16 -04:00
BFlat 843f46108c Merge pull request #509 from storytold/feat/macos-menu-bar
macOS: the menus live in the system menu bar, settings in the VectorCraft menu
2026-10-08 23:48:25 +07:00
bflatasticandClaude Opus 5.5 ab65474044 macOS: the menus live in the system menu bar, settings in the VectorCraft menu
On macOS the menus are in the system menu bar, laid out as a Mac app's (Illustrator on the Mac as
the reference), and the app settings sit in the VectorCraft menu: About, Settings ▸ (one item per
Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide
VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own
quit, so unsaved documents are asked about); they leave Edit and Help. Window gets Minimize ⌃⌘M,
Zoom and Bring All to Front; Help is the system's Help menu. Windows, Linux and the web keep the
in-window menu bar unchanged; --in-window-menus or VECTORCRAFT_IN_WINDOW_MENUS keeps it on macOS
too, and so does a menu bar AppKit refuses to build.

One menu model: the in-window menus and the native bar draw the same rows (menus::entry), and the
platform-free native_menu module lays them out, decides when to update (a cheap state hash; labels,
enabled and checked in place, a rebuild only when the structure changes) and hands key equivalents
back to egui as the key presses they were, so shortcuts, user overrides and focused text fields
behave as in the window and nothing fires twice. The muda backend (pinned =0.21.1, macOS only, no
unsafe) is apps/vectorcraft/src/mac_menu.rs, mirroring PhotoCraft's. ui.inspect reports
nativeMenuBar. Translations for the new labels in ja, es, zh-hans, zh-hant, cs and pt-br.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:45:31 +07:00
BFlat 8c4c665ee2 Merge pull request #540 from storytold/fix/eps-sources-restore
Fix #498, part of #505: EPS image sources read in turn, restore undoes dictionary changes (PLRM)
2026-10-08 23:44:50 +07:00
BFlat 4c2c606e25 Merge pull request #535 from storytold/pr/eps-spec-only-tests
EPS tests: interpreter programs written from the PostScript Language Reference
2026-10-08 23:44:33 +07:00
bflatasticandClaude Opus 5.5 b43b15a6e8 Part of #505: restore puts dictionaries back as they were at the save
The PostScript Language Reference (3rd ed., §3.7.3 "Save and Restore")
has `restore` reset composite objects in local VM to their values at the
`save`. The reader only restored the graphics state, so dictionary
entries changed between `save` and `restore` stayed changed: a program
that counts its open saves in a dictionary and refuses to go past a depth
stopped after that many saves in sequence, and the file fell back to its
preview. EPS files with many images, each drawn between `save` and
`restore`, are a likely case of #505; its reporter hasn't sent a file, so
this is part of it.

- While a `save` is open, `def`, `store`, `put`, `undef` and dictionary
  `copy` keep each entry's old value in a journal (counted against the
  memory budget); `restore` undoes it, newest change first, down to its
  save. A save restored already, itself or through an outer one, is no
  longer valid (`invalidrestore`, §3.7.3).
- `cachestatus` reports a device's cache sizes (chapter 8) instead of
  zeros: its last value, the most bytes one cached glyph may take, is what
  programs divide by.
- Tests (programs written from the reference): 100 saves counted in a
  dictionary under a depth limit, each dictionary change undone, saves
  restored twice, `cachestatus`'s glyph limit. Mutation-checked: without
  the journal or with a zero `cachestatus` the test fails. The import fuzz
  vocabulary has saves and restores around dictionary changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:43:51 +07:00
bflatasticandClaude Opus 5.5 1d265951df Fix #498: EPS images with several data sources read them in turn
An image can take one data source per colour component
(MultipleDataSources, or `colorimage` with multi true). The PostScript
Language Reference (3rd ed., §4.10 "Images" and `image`/`colorimage` in
chapter 8) has the operator read the sources in turn, the same amount
from each. The reader drained each source on its own instead: when the
sources are procedures reading one file a row at a time, the first plane
took the rows of every component and the image came out in sheared
horizontal bands, as in #498.

- Data sources are read in turn by one reader, `read_sources`: a procedure
  once a round, a file or string a row a round, until each plane is full or
  its source ends. Single sources, procedure filters and masked images use
  it too; a string source resumes where it left off.
- Tests (`tests_images.rs`, programs written from the reference):
  procedures reading one file in the dictionary and `colorimage` operand
  forms (fails without the fix), separate string sources, rows padded to a
  byte at 1, 2, 4, 12 and 16 bits with an odd width (one and several
  sources), Flate PNG predictors with every row filter at an odd width.
  The import fuzz vocabulary has several sources reading one file and
  sources of uneven lengths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:43:49 +07:00
bflatasticandClaude Opus 5.5 eb255ef379 EPS tests: interpreter programs written from the PostScript Language Reference
Two EPS import tests become programs written from the PostScript Language
Reference (3rd ed.), with their own names and structure, covering the
interpreter features the importer relies on:
- `level_2_and_3_probes_read_as_vectors`: a resource category made from
  Generic, resources defined and found in it (§3.9), local and global VM
  (`currentglobal`, `setglobal`, `gcheck`, §3.7.2), `internaldict` probed
  through `stopped`, a halftone dictionary set and restored, `cachestatus`,
  `rootfont` and `currentcolorrendering`, drawing with its own procedures.
- `executable_strings_and_integer_keys`: shading dictionaries closed by an
  executable string (`(>>) cvx`, or a Level 1 builder with `counttomark`),
  procedures kept under integer keys run with `load` through `stopped`,
  `cvx`/`cvlit`/`xcheck` on strings.
Each was mutation-checked: making any of findresource, defineresource,
currentglobal, setglobal, gcheck, internaldict, sethalftone,
currenthalftone, currentcolorrendering, cachestatus, rootfont, stopped,
cvx, load, counttomark, cvlit or xcheck unknown fails a test, and so do
strings that `cvx` leaves literal, `load` refusing integer keys and
`gcheck` answering true.

The ROADMAP's EPS paragraph, file-formats row and last-updated line state
the PostScript features the reader supports; the legacy `.ai` comments in
the import module and the interpreter cite that format's published
specification (PRONOM fmt/423) for the `%AI` header comments and `u`/`U`
group operators the reader keys on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:43:48 +07:00
bflatasticandClaude Opus 5.5 4eac7b505f Fix #506: drawing tools snap to Smart Guides as the Selection tool does
Every drawing tool now asks one snapper (guides::DrawSnap) for the points it
places: the Pen's and Curvature tool's anchors, the Pencil's ends, the shape
tools' start and dragged corner or radius (Rectangle, Rounded Rectangle,
Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's
click and frame and a new artboard's corners. Points land on other objects'
anchors, centres and paths ("anchor", "center", "path") or line up with their
anchors, edges and centres ("align"); hovering shows it before the press, the
Pen's guides end its rubber band, and the Pen lands on the path being drawn
too. Shift keeps a point to its 45-degree step (from Constrain Angle) or a
box's diagonal and slides it along that way into line with a target.
Preferences > Smart Guides > Construction Guides and Angles now act: a drawn
point near a guide through the point it leaves from lands on it.

Before, the Pen snapped only its press and showed nothing, and the shape tools
re-gathered targets on every drag step from the document showing their own
preview, so the dragged corner stuck to where the last step left it.

Performance: targets are gathered once per document state (keyed on the
document's uid and revision, now in ToolContext), from the art in the window,
and sorted, so the anchors, centres and lines near the pointer are found by
bisection. Leaving objects out of the targets no longer looks up each node's
ancestry (quadratic in the document's size): one pruned walk does it, which
also speeds up the Selection tool's moves and resizes.

The direction handles' HandleSnap (#494) is now a DrawSnap holding its own
targets (Targets::for_handle) for the drag: one cache, one lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:20:27 +07:00
BFlat 8b036df705 Merge pull request #515 from callmeBalloch/i18n-italian
Fix #514: Italian interface (it)
2026-10-08 23:14:48 +07:00
bflatasticandClaude Opus 5.5 7c5799aec6 i18n: Italian rows for strings added since the PR's base, consistency and fit fixes (review of #515)
- Italian rows for the strings main gained after the PR's base: the
  floating panels of #513 (Float Panel, Dock Panel, the drag hints, two
  window.float messages) and the GPU fallback status message of #519, so
  the completeness tests pass on current main.
- File > Document Setup... reads "Imposta documento..." like the dialog
  title and the Properties button, instead of "Impostazione documento...".
- The Properties panel's quick action "Arrange: Bring to Front" reads
  "Porta in primo piano": "Disponi: Porta in primo piano" was clipped at
  both ends of its fixed-width button.
- OpenType figure style "Proportional Oldstyle" reads "Proporzionali stile
  antico", matching "Tabulari stile antico".
- Blank line between the Italian test and the next item; ROADMAP's
  "Last updated" lists the Italian interface (#514).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:04:36 +07:00
callmeBallochandClaude Opus 5.5 4ba8a02107 Contributors: add callmeBalloch (Riccardo Balloch)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iayep93edeimiAE8oSfaT
2026-10-08 23:04:33 +07:00
callmeBallochandClaude Opus 5.5 7c7ae55625 i18n: Italian interface (it)
Adds an Italian catalog (crates/ui-egui/src/i18n/it.tsv, 3,549 rows in
es.tsv's order) covering every menu string, every tl! literal, the
plurals and every status/error message, at the same scope as Spanish.
Standard Italian in sentence case, written clean-room from the meaning
of the English labels; the product, workspace, perspective preset and
language names stay in English as in es and ja.

Registered as "Italiano" with the one/other plural rule and
complete_menus; every it-* locale resolves to it. Tests: it joins
KEEPS_MENU_NAMES and COMPLETE_MESSAGES, italian_reads_as_italian checks
vocabulary and plurals, tags_map_to_languages the locale tags, and the
bundled-font glyph test covers Italian accents and guillemets. README,
ROADMAP and docs/development.md list the language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iayep93edeimiAE8oSfaT
2026-10-08 23:04:31 +07:00
David PanuschandClaude Opus 5.5 b5759662c1 Control bar: Transform link opens the Transform panel; reference point locator redrawn
The Control bar's underlined Transform link opens the Transform panel as a
popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons
and angle presets, flips, shape properties, Scale Corners, Scale Strokes &
Effects), as the Stroke link does the Stroke panel. X/Y/W/H stay inline while
the bar has room.

The reference point locator is now nine squares with the outer eight joined
around the box; the old outline was inset off the squares' centres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:38:18 +02:00
imachusandClaude Opus 5.5 fba610284c Type (#432): new type in a Japanese interface starts top-to-top with em box centre alignment
As decided in #432 (option 2): while the interface is in Japanese, new type starts with em box
top-to-top leading and em box centre character alignment; other languages keep the Roman
baseline, so Latin area type doesn't move.

- The UI tells the engine the language it is drawn in each frame (`Session::ui_language`); without
  a UI, an explicit `interfaceLanguage` preference counts (`Session::japanese_interface`).
- `text.create` and `text.createInPath` (the Type tools' clicks and drags) apply it, and take
  `leadingModel` / `charAlign` to choose; the values used are journaled, so a replay does the same
  in any language.
- `paraStyle.new` / `charStyle.new` made from nothing (no `attrs`, no text selected) carry them.
- Imported text (PDF, SVG, DXF…) keeps the Roman baseline. Saved documents keep what they say.
- docs/mcp.md (New type in a Japanese interface) and the ROADMAP note it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:08:03 +09:00
FryandClaude Opus 4.8 e6a3b37bed UI: right-click a ruler to swap the document units
A right-click on either ruler (or the origin box) now opens a context
menu listing the ten document units, the current one checked. Choosing
one runs document.setUnits — the same one-undo-step change as
Preferences ▸ Units ▸ General / Document Setup.

The guide-drag interact on each ruler becomes click-and-drag so the same
widget both drags out guides (left) and opens the unit menu (right); the
origin box gets its own click interact. checked() reports the active unit
so the menu shows the check-mark; the unit labels are already translated
as the Units preference choices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 10:57:01 -04:00
imachusandClaude Opus 5.5 b7644d31f2 Fix #433: open only file URLs handed to the app (macOS 27 turns an https URL into a path)
`application:openURLs:` can pass URLs that aren't files, and on macOS 27 `to_file_path()` returns
the path of an https URL ("/a.svg"), so it was queued as a document. Keep the file URLs
(`isFileURL`) before converting; `opened_files_wait_for_the_ui` passes again on macOS 27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:54:22 +09:00
249 changed files with 29258 additions and 2024 deletions

No files matched your search

Generated
+33 -29
View File
@@ -2283,9 +2283,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.21.0"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2294,7 +2294,6 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
@@ -2777,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4297,7 +4296,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4313,15 +4312,17 @@ dependencies = [
"rfd",
"serde_json",
"vectorcraft-engine",
"vectorcraft-text",
"vectorcraft-ui-egui",
"webbrowser",
"windows",
"winit",
"winresource",
]
[[package]]
name = "vectorcraft-brush"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4333,7 +4334,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4349,8 +4350,9 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"log",
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4361,11 +4363,13 @@ dependencies = [
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"windows",
]
[[package]]
name = "vectorcraft-color"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4377,7 +4381,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4394,7 +4398,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4408,7 +4412,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4440,7 +4444,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4458,7 +4462,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"proptest",
@@ -4472,7 +4476,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4484,7 +4488,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"log",
@@ -4503,7 +4507,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4517,7 +4521,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4529,7 +4533,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"aes",
"flate2",
@@ -4558,7 +4562,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
@@ -4572,7 +4576,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"gif",
@@ -4597,7 +4601,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4621,7 +4625,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4638,7 +4642,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4656,7 +4660,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4671,7 +4675,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4684,7 +4688,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"egui",
"egui_extras",
@@ -4713,7 +4717,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -5816,7 +5820,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.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.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -1
View File
@@ -194,7 +194,7 @@ milestones, and honest time-to-parity estimates.
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
speaks English, Japanese, Traditional and Simplified Chinese and Spanish (and Czech and Brazilian Portuguese in the menus).
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian and Russian (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
+37 -32
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking intLine truncated
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -34,7 +34,7 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
@@ -42,7 +42,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows renders through DX12 before Vulkan (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
@@ -52,7 +52,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. Do it side by side with Illustrator where the owner allows. 60–90 h.
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
@@ -60,9 +60,10 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line
composer choice is done), tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 35–48 h across type core and advanced.
initial support. 34–47 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and
barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h.
@@ -101,23 +102,26 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 32 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 30 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Spacing Guides, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
@@ -130,18 +134,18 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish also translates status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian and Russian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
@@ -154,11 +158,12 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors. It also runs what other generators write (Discord feedback, #466, #469): Illustrator 2026's procsets (`gcheck`, `rootfont`, `currentcolorrendering`: every Illustrator 2026 EPS fell back to its preview before), cairo/Inkscape (`status`/`flushfile`, images with a mask, `ImageType 3`, reusable streams), matplotlib's Type 3 fonts, Illustrator 8 EPS (stock art's usual format: executable strings such as its gradients' `ed`, `load` by integer keys, #474), Ghostscript's `//name` immediate names, `pdfmark`, `token`, `execform` and user paths. Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
@@ -167,19 +172,19 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper, the remaining Pen modifier nuances) | 15–20 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
@@ -199,7 +204,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 |
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
@@ -207,19 +212,19 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 79% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image), per-feature stroke/fill choice and varying widths in Create Strokes (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 82% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451) | 3–5 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Guides, grids, smart guides, snapping, rulers | 3 | 84% | global/video rulers, Spacing Guides, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506) | 2–4 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 32 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
+7
View File
@@ -22,7 +22,14 @@ vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
vectorcraft-mcp = { workspace = true }
vectorcraft-render = { workspace = true }
vectorcraft-text = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
+16 -8
View File
@@ -9,7 +9,9 @@
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
//! ```
#![forbid(unsafe_code)]
// Denied, not forbidden: the DirectWrite font lister shared with the desktop app (Windows) allows
// it for its COM calls, and nothing else may.
#![deny(unsafe_code)]
use std::io::Write;
use std::process::ExitCode;
@@ -35,6 +37,10 @@ macro_rules! out {
}
mod perf;
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[path = "../../vectorcraft/src/system_fonts.rs"]
mod system_fonts;
use serde_json::{Value, json};
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
@@ -69,7 +75,8 @@ USAGE:
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
Live effects are kept; --outline-text writes SVG text as paths.
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
SVG's data-* attributes; --outline-text writes SVG text as paths.
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -92,6 +99,9 @@ fn usage() -> String {
}
fn main() -> ExitCode {
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("mcp") => mcp(&args[1..]),
@@ -177,12 +187,10 @@ fn convert(args: &[String]) -> Result<(), String> {
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
let mut h = Headless::new();
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
let r = h
.call(
"engine.execute",
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
)
.map_err(|e| format!("export {output}: {e}"))?;
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true});
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
outln!("{r}");
Ok(())
}
+3 -2
View File
@@ -6,7 +6,7 @@ use std::rc::Rc;
use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour};
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
@@ -200,7 +200,8 @@ fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
let Some(data) = e.clipboard_data() else { return };
let Some(files) = data.files().filter(|f| f.length() > 0) else { return };
if data.get_data("text").is_ok_and(|t| !t.is_empty()) {
// Text is egui's, but for the address a browser's Copy Image puts next to the picture.
if data.get_data("text").is_ok_and(|t| !t.is_empty() && !is_address(&t)) {
return;
}
e.prevent_default();
+10 -2
View File
@@ -19,6 +19,7 @@ windows7 = ["eframe/glow"]
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-text = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
@@ -36,10 +37,17 @@ winit = { version = "0.30", default-features = false }
[target.'cfg(windows)'.dependencies]
clipboard-win = { workspace = true }
# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as
# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
+28 -9
View File
@@ -4,9 +4,9 @@
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, and the power
//! preference ([`power_preference`]) among the rest. eframe is handed [`selector`], which takes
//! the first of that order.
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the power preference
//! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545).
//! eframe is handed [`selector`], which takes the first of that order.
//!
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
@@ -89,15 +89,17 @@ fn key(backend: Backend, vendor: u32, device: u32) -> String {
/// The order to try `candidates` in (their indices): only those that present to the window and
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
/// software; native backends before OpenGL; then by `power`, keeping the system's order among
/// equals (all of it for [`PowerPreference::None`]).
/// software; native backends before OpenGL; then by `power`; then DX12 before Vulkan, keeping the
/// system's order among equals (all of it for [`PowerPreference::None`]). Windows lists each GPU
/// under both, and Intel's Vulkan driver made the whole window flicker black where DX12 and OpenGL
/// didn't (#545); elsewhere there is no DX12 adapter, so the order is unchanged.
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec<usize> {
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
// Stable, so equals keep the system's order.
order.sort_by_key(|(_, c)| {
let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power))
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power), c.backend == Backend::Vulkan)
});
order.into_iter().map(|(i, _)| i).collect()
}
@@ -361,7 +363,7 @@ mod tests {
}
/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
/// integrated GPU first, as wgpu's own choice did (#306).
/// integrated GPU first, as wgpu's own choice did (#306), each GPU through DX12 first (#545).
#[test]
fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() {
let c = vec![
@@ -372,8 +374,25 @@ mod tests {
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
];
let order = adapter_order(&c, PowerPreference::LowPower, None, &[]);
assert_eq!(order, [1, 3, 0, 2, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [0, 2, 1, 3, 4]);
assert_eq!(order, [3, 1, 2, 0, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [2, 0, 3, 1, 4]);
}
/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black.
/// DX12 comes first whatever the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME`
/// or a restart leaving DX12 out still reach it.
#[test]
fn a_windows_gpu_renders_through_dx12_before_vulkan() {
let c = vec![
gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu),
gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other),
];
for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] {
assert_eq!(adapter_order(&c, power, None, &[]), [1, 0, 3, 2], "{power:?}");
}
assert_eq!(adapter_order(&c, PowerPreference::LowPower, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
}
#[test]
+487
View File
@@ -0,0 +1,487 @@
//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK
//! with default features off). The menus, their Mac layout and how their keys reach VectorCraft
//! are in `vectorcraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s.
//!
//! - Item ids are the items' places (`vc12`, depth first): one command can run from several items
//! (each language of VectorCraft › Language is `app.language` with its own code).
//! - A structure change (a recent file added, the language or a shortcut changed) rebuilds the
//! menu; labels, enabled and checked are updated in place.
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. A key
//! equivalent goes back to egui as the key press that was made (the event's modifiers, not the
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so VectorCraft's key rules still apply.
//! - Hide and Hide Others are items of ours calling `NSApplication`: AppKit's own always take ⌘H
//! (View › Hide Edges) and ⌥⌘H.
use std::sync::Once;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use egui::{Key, KeyboardShortcut};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType, NSMenuDidBeginTrackingNotification, NSMenuDidEndTrackingNotification};
use objc2_foundation::{NSNotification, NSNotificationCenter};
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, Node, Standard};
// AppKit can deliver window input while its system menu is still tracking (especially with
// asynchronous menu presentation). Keep the native tracking state separate from egui popups.
static TRACKING: AtomicBool = AtomicBool::new(false);
fn observe_tracking(ctx: &egui::Context) {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
// SAFETY: AppKit initializes these immutable notification-name constants.
#[allow(unsafe_code)]
let notifications = unsafe { [(NSMenuDidBeginTrackingNotification, true), (NSMenuDidEndTrackingNotification, false)] };
for (name, tracking) in notifications {
let repaint = ctx.clone();
let block = RcBlock::new(move |_: std::ptr::NonNull<NSNotification>| {
TRACKING.store(tracking, Ordering::Relaxed);
// Apply any deferred menu updates as soon as the tracking session ends.
if !tracking {
repaint.request_repaint();
}
});
// SAFETY: these are AppKit's notification names. The notification center retains
// the observer and block for the application's lifetime; callbacks update an atomic and request repaint.
#[allow(unsafe_code)]
unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(Some(name), None, None, &block);
}
}
});
}
fn cancel_tracking() {
if let Some(mtm) = MainThreadMarker::new()
&& let Some(menu) = NSApplication::sharedApplication(mtm).mainMenu()
{
menu.cancelTracking();
}
TRACKING.store(false, Ordering::Relaxed);
}
/// A dismissing click belongs to the menu, so the canvas and panel controls must not see it.
fn dismiss_input(raw: &mut egui::RawInput, tracking: bool) -> bool {
if !raw
.events
.iter()
.any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }))
{
return false;
}
if tracking {
raw.events.retain(|e| !matches!(e, egui::Event::PointerButton { .. } | egui::Event::Key { key: egui::Key::Escape, .. }));
}
true
}
/// Inspect real window input before native key equivalents and control events are added.
pub fn raw_input_hook(raw: &mut egui::RawInput) {
// Asynchronous menu presentation may leave a displayed menu after its tracking notification.
// Still cancel it, but consume the dismissing input only during a reported tracking session.
if dismiss_input(raw, TRACKING.load(Ordering::Relaxed)) {
cancel_tracking();
}
}
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
/// One native item, in the order of [`MenuBar::items`].
struct Entry {
handle: Handle,
item: native_menu::Item,
/// Its shortcut, when it is a key equivalent.
chord: Option<KeyboardShortcut>,
}
/// How an item was chosen, read from AppKit's current event while it calls the handler.
struct Chosen {
id: String,
/// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧.
key: bool,
mods: [bool; 4],
}
pub struct MacMenu {
menu: Option<Menu>,
entries: Vec<Entry>,
rx: Receiver<Chosen>,
structure: u64,
/// Latest menu state to apply after AppKit finishes tracking; item ids stay stable meanwhile.
pending: Option<MenuBar>,
}
impl MacMenu {
fn new(ctx: &egui::Context) -> MacMenu {
observe_tracking(ctx);
let (tx, rx) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
// AppKit calls this on the main thread, from the event that chose the item: a key
// equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent());
let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown);
let flags = event.as_ref().map_or(NSEventModifierFlags::empty(), |ev| ev.modifierFlags());
let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift]
.map(|m| flags.contains(m));
// The receiver only goes away with the app.
let _ = tx.send(Chosen { id: e.id.0, key, mods });
// Wake egui, so the item runs now rather than on the next mouse move.
repaint.request_repaint();
}));
MacMenu { menu: None, entries: Vec::new(), rx, structure: 0, pending: None }
}
/// Build the menus of `bar` and make them the app's, in place of the ones before.
fn build(&mut self, bar: &MenuBar) -> muda::Result<()> {
let mut entries = Vec::new();
let menu = Menu::new();
let mut special = Vec::new();
for m in &bar.menus {
let sub = Submenu::new(escape(&m.title), true);
append(&sub, &m.children, &mut entries)?;
menu.append(&sub)?;
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
special.push((m.role, sub));
}
}
if let Some(old) = self.menu.take() {
old.remove_for_nsapp();
}
menu.init_for_nsapp();
// Only once the menu is the app's: muda looks the submenus up in the installed main menu.
for (role, sub) in special {
match role {
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
_ => sub.set_as_help_menu_for_nsapp(),
}
}
self.menu = Some(menu);
self.entries = entries;
self.structure = native_menu::structure_key(bar);
Ok(())
}
}
fn append(sub: &Submenu, nodes: &[Node], entries: &mut Vec<Entry>) -> muda::Result<()> {
for n in nodes {
match n {
Node::Separator => sub.append(&PredefinedMenuItem::separator())?,
Node::Header(label) => sub.append(&MenuItem::new(escape(label), false, None))?,
Node::Submenu { label, children } => {
let s = Submenu::new(escape(label), !children.is_empty());
append(&s, children, entries)?;
sub.append(&s)?;
}
Node::Standard(item) => {
let text = Some(vectorcraft_ui_egui::i18n::t(item.label()));
let p = match item {
Standard::Services => PredefinedMenuItem::services(text),
Standard::ShowAll => PredefinedMenuItem::show_all(text),
Standard::Zoom => PredefinedMenuItem::maximize(text),
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(text),
};
sub.append(&p)?;
}
Node::Item(it) => {
let id = format!("vc{}", entries.len());
let chord = it.shortcut.and_then(vectorcraft_ui_egui::shortcuts::parse).filter(native_menu::native_ok);
let accel = chord.as_ref().and_then(accelerator);
let handle = match it.checked {
Some(c) => {
let h = CheckMenuItem::with_id(id, escape(&it.label), it.enabled, c, accel);
sub.append(&h)?;
Handle::Check(h)
}
None => {
let h = MenuItem::with_id(id, escape(&it.label), it.enabled, accel);
sub.append(&h)?;
Handle::Plain(h)
}
};
entries.push(Entry { handle, item: it.clone(), chord });
}
}
}
Ok(())
}
impl Backend for MacMenu {
fn sync(&mut self, bar: &MenuBar) {
// Replacing or mutating an NSMenu during tracking can strand its displayed menu. Keep
// the latest state: the shared model considers this sync complete and may not resend it.
if TRACKING.load(Ordering::Relaxed) {
self.pending = Some(bar.clone());
return;
}
self.pending = None;
let items = bar.items();
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure || items.len() != self.entries.len() {
if let Err(e) = self.build(bar) {
// The menus as they were stay up (a failed build changed nothing).
log::warn!("couldn't rebuild the macOS menu bar: {e}");
}
return;
}
for (e, it) in self.entries.iter_mut().zip(items) {
if e.item.label != it.label {
let text = escape(&it.label);
match &e.handle {
Handle::Plain(h) => h.set_text(text),
Handle::Check(h) => h.set_text(text),
}
}
if e.item.enabled != it.enabled {
match &e.handle {
Handle::Plain(h) => h.set_enabled(it.enabled),
Handle::Check(h) => h.set_enabled(it.enabled),
}
}
// Compare with AppKit's own state: muda toggles a check item when it's clicked.
if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked)
&& h.is_checked() != c
{
h.set_checked(c);
}
if e.item != *it {
e.item = it.clone();
}
}
}
fn drain(&mut self) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(chosen) = self.rx.try_recv() {
cancel_tracking();
// Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled.
let Some(e) = chosen.id.strip_prefix("vc").and_then(|n| n.parse::<usize>().ok()).and_then(|i| self.entries.get(i)) else { continue };
if let Some(command @ (native_menu::HIDE | native_menu::HIDE_OTHERS)) = e.item.command {
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
if command == native_menu::HIDE { app.hide(None) } else { app.hideOtherApplications(None) }
}
continue;
}
// A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open
// menu (no ⌘ or ⌃ while the item's shortcut has one). The key is the item's (AppKit
// matched on it); the modifiers are the ones held.
let [cmd, ctrl, alt, shift] = chosen.mods;
let key = e
.chord
.filter(|c| {
chosen.key && e.item.command != Some(native_menu::MINIMIZE) && (cmd || ctrl || !(c.modifiers.command || c.modifiers.ctrl))
})
.map(|c| KeyboardShortcut::new(egui::Modifiers { alt, ctrl, shift, mac_cmd: cmd, command: cmd }, c.logical_key));
out.push(match key {
Some(k) => Event::Key(k),
None => Event::Click(e.item.clone()),
});
}
// Drain against the old item ids before a deferred structure change replaces them.
if !TRACKING.load(Ordering::Relaxed)
&& let Some(bar) = self.pending.take()
{
self.sync(&bar);
}
out
}
}
/// Install the menu bar (in eframe's creator closure, so winit's default menu doesn't stay up).
/// `None` keeps the menus in the window: AppKit refused them (logged), never a crash.
pub fn install(ctx: &egui::Context, app: &VectorcraftApp) -> Option<native_menu::NativeMenu> {
vectorcraft_ui_egui::i18n::set_current(app.ui_language());
let bar = native_menu::layout(app).bar;
let built = vectorcraft_engine::guard::catch_panic(|| {
let mut menu = MacMenu::new(ctx);
menu.build(&bar).map(|()| menu)
});
match built {
Ok(Ok(menu)) => Some(native_menu::NativeMenu::new(Box::new(menu))),
Ok(Err(e)) => {
log::warn!("couldn't install the macOS menu bar ({e}); the menus stay in the window");
None
}
Err(e) => {
log::warn!("couldn't install the macOS menu bar ({e}); the menus stay in the window");
None
}
}
}
/// `&` marks a mnemonic in muda labels: "Fill & Stroke" keeps its ampersand.
fn escape(s: &str) -> String {
s.replace('&', "&&")
}
/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together.
fn accelerator(c: &KeyboardShortcut) -> Option<Accelerator> {
let m = c.modifiers;
let mut mods = Modifiers::empty();
for (on, flag) in [(m.command || m.mac_cmd, Modifiers::META), (m.ctrl, Modifiers::CONTROL), (m.alt, Modifiers::ALT), (m.shift, Modifiers::SHIFT)]
{
if on {
mods |= flag;
}
}
Some(Accelerator::new(mods, code(c.logical_key)?))
}
/// The key code AppKit matches for an egui key.
fn code(key: Key) -> Option<Code> {
Some(match key {
Key::A => Code::KeyA,
Key::B => Code::KeyB,
Key::C => Code::KeyC,
Key::D => Code::KeyD,
Key::E => Code::KeyE,
Key::F => Code::KeyF,
Key::G => Code::KeyG,
Key::H => Code::KeyH,
Key::I => Code::KeyI,
Key::J => Code::KeyJ,
Key::K => Code::KeyK,
Key::L => Code::KeyL,
Key::M => Code::KeyM,
Key::N => Code::KeyN,
Key::O => Code::KeyO,
Key::P => Code::KeyP,
Key::Q => Code::KeyQ,
Key::R => Code::KeyR,
Key::S => Code::KeyS,
Key::T => Code::KeyT,
Key::U => Code::KeyU,
Key::V => Code::KeyV,
Key::W => Code::KeyW,
Key::X => Code::KeyX,
Key::Y => Code::KeyY,
Key::Z => Code::KeyZ,
Key::Num0 => Code::Digit0,
Key::Num1 => Code::Digit1,
Key::Num2 => Code::Digit2,
Key::Num3 => Code::Digit3,
Key::Num4 => Code::Digit4,
Key::Num5 => Code::Digit5,
Key::Num6 => Code::Digit6,
Key::Num7 => Code::Digit7,
Key::Num8 => Code::Digit8,
Key::Num9 => Code::Digit9,
Key::F1 => Code::F1,
Key::F2 => Code::F2,
Key::F3 => Code::F3,
Key::F4 => Code::F4,
Key::F5 => Code::F5,
Key::F6 => Code::F6,
Key::F7 => Code::F7,
Key::F8 => Code::F8,
Key::F9 => Code::F9,
Key::F10 => Code::F10,
Key::F11 => Code::F11,
Key::F12 => Code::F12,
Key::F13 => Code::F13,
Key::F14 => Code::F14,
Key::F15 => Code::F15,
Key::F16 => Code::F16,
Key::F17 => Code::F17,
Key::F18 => Code::F18,
Key::F19 => Code::F19,
Key::F20 => Code::F20,
Key::F21 => Code::F21,
Key::F22 => Code::F22,
Key::F23 => Code::F23,
Key::F24 => Code::F24,
Key::OpenBracket => Code::BracketLeft,
Key::CloseBracket => Code::BracketRight,
Key::Backslash => Code::Backslash,
Key::Slash => Code::Slash,
// `+` is Shift+`=` on the keyboards AppKit matches by key: Zoom In shows ⌘=.
Key::Equals | Key::Plus => Code::Equal,
Key::Minus => Code::Minus,
Key::Quote => Code::Quote,
Key::Semicolon => Code::Semicolon,
Key::Comma => Code::Comma,
Key::Period => Code::Period,
Key::Backtick => Code::Backquote,
Key::Backspace => Code::Backspace,
Key::Delete => Code::Delete,
Key::Enter => Code::Enter,
Key::Tab => Code::Tab,
Key::Space => Code::Space,
Key::Escape => Code::Escape,
Key::ArrowLeft => Code::ArrowLeft,
Key::ArrowRight => Code::ArrowRight,
Key::ArrowUp => Code::ArrowUp,
Key::ArrowDown => Code::ArrowDown,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Every shortcut the Mac menu shows as a key equivalent has one AppKit can match.
#[test]
fn every_menu_shortcut_maps_to_a_key_equivalent() {
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let bar = native_menu::layout(&app).bar;
let mut native = 0;
for it in bar.items() {
let Some(c) = it.shortcut.and_then(vectorcraft_ui_egui::shortcuts::parse).filter(native_menu::native_ok) else { continue };
assert!(accelerator(&c).is_some(), "{:?} ({:?}) has no key equivalent", it.shortcut, it.command);
native += 1;
}
assert!(native > 70, "{native} key equivalents");
}
#[test]
fn control_and_command_stay_distinct() {
let parse = |s| vectorcraft_ui_egui::shortcuts::parse(s).and_then(|c| accelerator(&c));
assert_ne!(parse("Cmd+M"), parse("Ctrl+Cmd+M"));
assert!(parse("Cmd+M").is_some());
}
fn press() -> egui::Event {
egui::Event::PointerButton {
pos: egui::pos2(300.0, 200.0),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
}
}
#[test]
fn outside_press_dismisses_without_starting_a_canvas_gesture() {
let moved = egui::Event::PointerMoved(egui::pos2(300.0, 200.0));
let mut raw = egui::RawInput { events: vec![moved.clone(), press()], ..Default::default() };
assert!(dismiss_input(&mut raw, true));
assert_eq!(raw.events, [moved]);
assert!(!dismiss_input(&mut raw, true));
raw.events.push(press());
assert!(dismiss_input(&mut raw, false));
assert_eq!(raw.events.len(), 2, "ordinary canvas presses still reach egui");
}
#[test]
fn escape_dismisses_without_reaching_the_active_tool() {
let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
let mut raw = egui::RawInput { events: vec![escape(true), escape(false)], ..Default::default() };
assert!(dismiss_input(&mut raw, true));
assert!(raw.events.is_empty());
}
#[test]
fn moving_the_pointer_does_not_dismiss_a_tracking_menu() {
let mut raw = egui::RawInput { events: vec![egui::Event::PointerMoved(egui::pos2(300.0, 200.0))], ..Default::default() };
assert!(!dismiss_input(&mut raw, true));
assert_eq!(raw.events.len(), 1);
}
}
+92 -44
View File
@@ -1,6 +1,9 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port>] [files…]`
//! Usage: `vectorcraft [--control <port>] [--in-window-menus] [files…]`
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
@@ -20,15 +23,18 @@ mod control_server;
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod mac_menu;
#[cfg(target_os = "macos")]
mod open_documents;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::picks::PickRequest;
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
struct App {
@@ -37,8 +43,6 @@ struct App {
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
graphics_lost: bool,
#[cfg(target_os = "macos")]
menu: Option<native_menu::NativeMenu>,
}
impl eframe::App for App {
@@ -59,15 +63,7 @@ impl eframe::App for App {
return;
}
#[cfg(target_os = "macos")]
{
if self.menu.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
self.menu = Some(native_menu::NativeMenu::install(&mut self.app));
}
if let Some(m) = &mut self.menu {
m.poll(&mut self.app, ctx);
}
open_files(&mut self.app, open_documents::take());
}
open_files(&mut self.app, open_documents::take());
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.app.ui.status == "quit" {
@@ -75,6 +71,10 @@ impl eframe::App for App {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -186,9 +186,22 @@ fn save_prefs(app: &VectorcraftApp) {
}
}
/// A native file dialog showing `pick`'s file types, folder and suggested name.
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
/// shows it over the window and keeps it in front; macOS shows them as before).
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
let d = match parent {
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
_ => rfd::FileDialog::new(),
};
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
PickRequest::Folder => return d,
};
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
let d = match &pick.folder {
Some(folder) => d.set_directory(folder),
None => d,
@@ -196,6 +209,28 @@ fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(pick) => {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
dialog.save_file().into_iter().collect()
}
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request)
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
@@ -231,25 +266,31 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
fn services() -> Services {
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request));
})
.ok()?;
Some(rx)
}
fn services(parent: Parent) -> Services {
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
Services {
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_open_multi: Some(Box::new(|| {
fileio::place_filters()
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_save: Some(Box::new(|pick: &FilePick| {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
})),
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Windows and macOS dialogs run the window's events while they are open; Linux's don't.
start_pick: cfg!(all(unix, not(target_os = "macos")))
.then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
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.
@@ -267,7 +308,7 @@ fn services() -> Services {
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
@@ -317,12 +358,17 @@ fn main() -> std::process::ExitCode {
// First, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
@@ -393,7 +439,7 @@ fn main() -> std::process::ExitCode {
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
@@ -434,15 +480,17 @@ fn main() -> std::process::ExitCode {
app = app.with_control(rx);
}
#[cfg(target_os = "macos")]
open_documents::set_ui(&cc.egui_ctx);
{
open_documents::set_ui(&cc.egui_ctx);
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
if !in_window_menus {
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
}
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
open_files(&mut app, files);
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
#[cfg(target_os = "macos")]
menu: None,
}))
Ok(Box::new(App { app, graphics_loss, graphics_lost: false }))
}),
)
});
-183
View File
@@ -1,183 +0,0 @@
//! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac).
//! Items dispatch through the same command path as the in-window menus; enablement, check marks
//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt
//! when saved views or recent files come and go.
use std::collections::HashMap;
use std::str::FromStr;
use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::i18n::{self, Lang, t};
use vectorcraft_ui_egui::menus::{self, Item};
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
pub struct NativeMenu {
_menu: Menu,
items: HashMap<String, (String, Value, Handle, String)>,
last_refresh: f64,
/// Shortcut-override / workspace-list generation the menu was built for.
generation: u64,
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
slots: usize,
/// Plug-in registry revision the Plug-ins submenus were built for.
plugins: u64,
/// The language the labels were built in (a change rebuilds the menu).
language: Lang,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
/// don't steal keys from text fields; a focused field still gets Cmd+A/X/C/V, see [`NativeMenu::poll`]).
fn accel(sc: &str) -> Option<Accelerator> {
if !(sc.contains("Cmd") || sc.contains("Ctrl") || sc.contains("Alt") || sc.starts_with('F')) {
return None;
}
Accelerator::from_str(&sc.replace("Cmd", "CMD").replace("Alt", "ALT").replace("Shift", "SHIFT").replace("Ctrl", "CTRL")).ok()
}
impl NativeMenu {
pub fn install(app: &mut VectorcraftApp) -> Self {
// Accelerators come from `shortcut_of`, which honours the user's overrides.
vectorcraft_ui_egui::shortcut_editor::sync(&app.ui);
let generation = vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed);
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(t(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
menu.init_for_nsapp();
app.native_menu = true;
app.native_shortcuts = items
.values()
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self {
_menu: menu,
items,
last_refresh: 0.0,
generation,
slots: menus::listed_slots(app),
plugins: menus::plugin_revision(),
language: i18n::current(),
}
}
/// Dispatch chosen items (a focused text field takes Select All and the clipboard) and refresh
/// state.
pub fn poll(&mut self, app: &mut VectorcraftApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, params, _, _)) = self.items.get(ev.id.as_ref()) {
let p = if params.is_null() { serde_json::json!({}) } else { params.clone() };
menus::invoke_from_system_menu(app, ctx, cmd, p);
}
}
let now = vectorcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
// Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language
// changed: rebuild so accelerators, lists and labels are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| i18n::current() != self.language
{
*self = NativeMenu::install(app);
return;
}
for (run, rp, h, cmd) in self.items.values() {
let null = Value::Null;
let params = if run == cmd { rp } else { &null };
let en = menus::enabled(app, cmd);
match h {
Handle::Plain(i) => {
i.set_enabled(en);
if matches!(
cmd.as_str(),
"edit.undo"
| "edit.redo"
| "view.outline"
| "view.rulers"
| "view.guides"
| "view.grid"
| "view.edges"
| "view.artboards"
| "view.boundingBox"
| "view.transparencyGrid"
| "window.workspace.reset"
) || cmd.starts_with("file.openRecent")
|| cmd.starts_with("type.recentFont")
|| cmd.starts_with("view.goto")
|| matches!(
cmd.as_str(),
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(menus::display_label(app, cmd, ""));
}
}
Handle::Check(c) => {
c.set_enabled(en);
c.set_checked(menus::checked(app, cmd, params).unwrap_or(false));
}
}
}
}
}
fn build(
app: &VectorcraftApp,
parent: &Submenu,
entries: &[Item],
items: &mut HashMap<String, (String, Value, Handle, String)>,
counter: &mut usize,
) {
for e in entries {
match e {
Item::Sep => {
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(t(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(t(label), true);
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
// Unused saved-view and recent-file slots are left out, as in the in-window menus.
Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {}
Item::Cmd(label, cmd, params) => {
*counter += 1;
let id = format!("dc{counter}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
let (run, run_params) = menus::click_target(label, cmd, params);
let _ = &run_params;
items.insert(id, (run, run_params, handle, cmd.to_string()));
}
}
}
}
+2 -1
View File
@@ -78,7 +78,8 @@ fn add_open_urls_method() {
/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit.
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
let paths = urls.iter().filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
// Only file URLs: macOS 27 turns an https URL's path into a file path too (#433).
let paths = urls.iter().filter(|u| u.isFileURL()).filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
if let Some(ctx) = UI.get() {
ctx.request_repaint();
+92
View File
@@ -0,0 +1,92 @@
//! Windows: the font files in DirectWrite's system font collection, scanned with the font folders
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
//! MCP) both install it; the CLI shares this file.
//!
//! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their
//! own folders, without installing them: the font folders and the registry don't list them. Since
//! Windows 10 (version 1607) DirectWrite's system font collection holds them too
//! (<https://learn.microsoft.com/windows/win32/directwrite/what-s-new-in-directwrite-for-windows-8-consumer-preview>,
//! "Support for Adobe Typekit and other font-service clients"), so VectorCraft lists them as other
//! apps do, reading the files in place (#579).
//!
//! DirectWrite is COM, which has no safe API, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use std::collections::BTreeSet;
use windows::Win32::Graphics::DirectWrite::{
DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory3, IDWriteFontSet, IDWriteLocalFontFileLoader,
};
use windows::core::Interface;
/// The most fonts read from the collection (a system has a few thousand).
const MAX_FONTS: u32 = 1 << 16;
/// The longest file path taken, in UTF-16 units (Windows' own limit).
const MAX_PATH: u32 = 32_767;
/// Have the font scans read the font files DirectWrite lists.
pub fn install() {
vectorcraft_text::set_platform_font_files(font_files);
}
/// The local files of the fonts in DirectWrite's system font collection as it is now (fonts
/// loaded since the last call included). None before Windows 10, or when DirectWrite fails: the
/// font folders and the registry still list the installed fonts.
fn font_files() -> Vec<String> {
match collection_files() {
Ok(files) => files.into_iter().collect(),
Err(e) => {
log::debug!("DirectWrite's system fonts aren't listed: {e}");
Vec::new()
}
}
}
fn collection_files() -> windows::core::Result<BTreeSet<String>> {
// SAFETY: plain DirectWrite calls on interfaces it returned, each checked for failure; the
// collection is asked to check for fonts loaded since the factory last looked.
let set = unsafe {
let factory: IDWriteFactory3 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?;
let mut collection = None;
factory.GetSystemFontCollection(false, &mut collection, true)?;
let Some(collection) = collection else { return Ok(BTreeSet::new()) };
collection.GetFontSet()?
};
// SAFETY: a count of the set's fonts.
let count = unsafe { set.GetFontCount() }.min(MAX_FONTS);
// Faces of one file (a collection's, a variable font's instances) name it once.
Ok((0..count).filter_map(|i| local_path(&set, i)).collect())
}
/// The path of the file holding font `i` of `set`, when it is a local file (not a downloadable
/// font, nor one in memory).
fn local_path(set: &IDWriteFontSet, i: u32) -> Option<String> {
// SAFETY: `i` is below the set's count; the key DirectWrite hands back stays valid while
// `file` (which owns it) lives, and is passed back with its own size; the path buffer is
// sized from DirectWrite's answer plus the terminating NUL.
unsafe {
let file = set.GetFontFaceReference(i).ok()?.GetFontFile().ok()?;
let loader: IDWriteLocalFontFileLoader = file.GetLoader().ok()?.cast().ok()?;
let (mut key, mut size) = (std::ptr::null_mut(), 0u32);
file.GetReferenceKey(&mut key, &mut size).ok()?;
let len = loader.GetFilePathLengthFromKey(key, size).ok()?;
if len == 0 || len > MAX_PATH {
return None;
}
let mut path = vec![0u16; len as usize + 1];
loader.GetFilePathFromKey(key, size, &mut path).ok()?;
Some(String::from_utf16_lossy(path.get(..len as usize)?))
}
}
#[cfg(test)]
mod tests {
/// DirectWrite lists the installed fonts, by their files' full paths.
#[test]
fn directwrite_lists_the_installed_font_files() {
let files = super::font_files();
assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first());
let fonts = std::path::Path::new(&std::env::var("WINDIR").unwrap()).join("Fonts").to_string_lossy().to_lowercase();
assert!(files.iter().any(|f| f.to_lowercase().starts_with(&fonts)), "{} files, {:?}", files.len(), files.first());
}
}
+14
View File
@@ -21,3 +21,17 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.callmeBalloch]
real_name = "Riccardo Balloch"
sync_github_name = false
[people.mo0kid]
sync_github_name = true
[people.omd0]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+1 -1
View File
@@ -315,7 +315,7 @@ impl<'a> Scene<'a> {
}
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
+332 -8
View File
@@ -1,6 +1,9 @@
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
//! headers) and CSS custom properties (written only).
//! headers) and CSS custom properties (written only). Swatch exchange files (`.ase`) are read
//! only ([`read_bytes`]).
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
@@ -58,6 +61,15 @@ struct NativeFile {
const NATIVE_FORMAT: &str = "vcswatches";
const GPL_HEADER: &str = "GIMP Palette";
const GPL_GROUP: &str = "# Group:";
const ASE_SIGNATURE: &[u8] = b"ASEF";
const ASE_GROUP_START: u16 = 0xC001;
const ASE_GROUP_END: u16 = 0xC002;
const ASE_COLOR: u16 = 0x0001;
/// The most colors and groups read from one `.ase` file.
const ASE_MAX_ENTRIES: usize = 100_000;
/// The most UTF-16 code units of a name that are kept.
const ASE_MAX_NAME: usize = 1024;
const ASE_CUT_SHORT: &str = "the swatch exchange file is cut short";
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
@@ -199,16 +211,34 @@ pub fn sniff(text: &str) -> bool {
t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT))
}
/// Read the bytes of a library file: a swatch exchange `.ase` file, else a `.vcswatches` or
/// `.gpl` library ([`read`]). `name` names it when the file doesn't.
pub fn read_bytes(bytes: &[u8], name: &str) -> Result<SwatchLibrary, String> {
if bytes.starts_with(ASE_SIGNATURE) {
return read_ase(bytes, name);
}
read(&String::from_utf8_lossy(bytes), name)
}
/// Are `bytes` a library [`read_bytes`] understands? Text that isn't valid UTF-8 is checked as
/// [`read_bytes`] reads it, with the invalid bytes replaced (its first KiB is enough).
pub fn sniff_bytes(bytes: &[u8]) -> bool {
bytes.starts_with(ASE_SIGNATURE)
|| std::str::from_utf8(bytes).is_ok_and(sniff)
|| sniff(&String::from_utf8_lossy(bytes.get(..1024).unwrap_or(bytes)))
}
/// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are
/// named by their values; repeated names get a number.
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colors are
/// named by their values and unnamed groups "Color Group"; a name a color or group already has gets
/// a number ([`UniqueNames`]).
fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
let mut names: Vec<String> = vec![];
let mut names = UniqueNames::default();
for line in text.lines().skip(1) {
let line = line.trim();
if let Some(g) = line.strip_prefix(GPL_GROUP) {
lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] });
lib.groups.push(SwatchGroup { name: names.unique(group_name(g)), swatches: vec![] });
continue;
}
if let Some(n) = line.strip_prefix("Name:") {
@@ -221,9 +251,7 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
let [r, g, b] = rgb[..] else { continue };
let rest = words[3..].join(" ");
let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest };
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base);
names.push(name.clone());
let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
let w = Swatch { name: names.unique(base), paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
match lib.groups.last_mut() {
Some(grp) => grp.swatches.push(w),
None => lib.swatches.push(w),
@@ -232,6 +260,184 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
lib
}
/// `.ase`, from public descriptions of the format: the layout from
/// <https://www.selapa.net/swatches/colors/fileformats.php>, the component ranges from
/// <https://github.com/nsfmc/swatch>. After the signature come, big-endian, a `u16` major (1) and
/// minor version, a `u32` block count and the blocks, each a `u16` type, a `u32` body length and
/// the body. A group start's body is a name. A color's body is a name, a four-character model
/// (`RGB `, `CMYK`, `LAB `, `Gray`), its components as `f32` (3, 4, 3 and 1 of them) and a `u16`
/// color type (0 global, 1 spot, 2 process). RGB, CMYK and gray components run from 0 to 1 (a
/// gray level of 1 is white), Lab lightness from 0 to 1 and a and b from −128 to 127. A lightness
/// above 1 is read as L* itself. A name is a `u16` count of UTF-16 code units, the terminating zero
/// included, then the units.
///
/// Color groups become groups: a group start inside an open group ends that group, and a group
/// left open ends with the file. Blocks of other types and colors in other models are skipped.
/// Unnamed colors are named by their values and unnamed groups "Color Group"; a name a color or
/// group already has gets a number ([`UniqueNames`]). A truncated file, a major version other than
/// 1 or more than [`ASE_MAX_ENTRIES`] colors and groups is an error.
fn read_ase(bytes: &[u8], fallback: &str) -> Result<SwatchLibrary, String> {
let mut data = AseData(bytes);
data.bytes(ASE_SIGNATURE.len())?;
let (major, minor) = (data.u16()?, data.u16()?);
if major != 1 {
return Err(format!("swatch exchange version {major}.{minor} isn't supported (only 1.x)"));
}
let blocks = data.u32()?;
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
let mut names = UniqueNames::default();
let (mut in_group, mut entries) = (false, 0usize);
for _ in 0..blocks {
let kind = data.u16()?;
let len = usize::try_from(data.u32()?).unwrap_or(usize::MAX);
let mut body = AseData(data.bytes(len)?);
if matches!(kind, ASE_GROUP_START | ASE_COLOR) {
entries += 1;
if entries > ASE_MAX_ENTRIES {
return Err(format!("the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups"));
}
}
match kind {
ASE_GROUP_START => {
lib.groups.push(SwatchGroup { name: names.unique(group_name(&body.name()?)), swatches: vec![] });
in_group = true;
}
ASE_GROUP_END => in_group = false,
ASE_COLOR => {
let Some(mut w) = ase_swatch(&mut body)? else { continue };
w.name = names.unique(std::mem::take(&mut w.name));
match lib.groups.last_mut().filter(|_| in_group) {
Some(g) => g.swatches.push(w),
None => lib.swatches.push(w),
}
}
// Other blocks are skipped.
_ => {}
}
}
Ok(lib)
}
/// The swatch of an `.ase` color block, `None` for a model other than RGB, CMYK, Lab and Gray.
/// Components are clamped to their ranges.
fn ase_swatch(body: &mut AseData) -> Result<Option<Swatch>, String> {
let name = body.name()?;
let unit = |v: f32| v.clamp(0.0, 1.0);
let color = match &body.array::<4>()? {
b"RGB " => {
let [r, g, b] = [body.f32()?, body.f32()?, body.f32()?];
Color::rgb(unit(r), unit(g), unit(b))
}
b"CMYK" => {
let [c, m, y, k] = [body.f32()?, body.f32()?, body.f32()?, body.f32()?];
Color::cmyk(unit(c), unit(m), unit(y), unit(k))
}
// Lightness is stored as a fraction of 100. Some writers store L* itself, which shows as a
// value above 1.
b"LAB " => {
let [l, a, b] = [body.f32()?, body.f32()?, body.f32()?];
let l = if l > 1.0 { l.min(100.0) } else { unit(l) * 100.0 };
Color::lab(l, a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
}
// A gray level (1 is white); a gray color holds its ink.
b"Gray" => Color::gray(1.0 - unit(body.f32()?)),
_ => return Ok(None),
};
// Some writers leave the color type out, which makes a process color.
let kind = if body.0.len() >= 2 { body.u16()? } else { 2 };
// Spot colors are global.
let (global, spot) = match kind {
0 => (true, false),
1 => (true, true),
_ => (false, false),
};
let name = if name.is_empty() { value_name(color) } else { name };
Ok(Some(Swatch { name, paint: Paint::solid(color), global, spot }))
}
/// A color's values in its own model, as new swatches are named ("C=10 M=20 Y=30 K=0",
/// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30").
fn value_name(c: Color) -> String {
let pct = |v: f32| (v * 100.0).round();
let byte = |v: f32| (v * 255.0).round();
match c {
Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)),
Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)),
Color::Gray { k } => format!("Gray K={}", pct(k)),
// `+ 0.0` turns a rounded -0 into 0.
Color::Lab { l, a, b } => format!("L={} a={} b={}", l.round() + 0.0, a.round() + 0.0, b.round() + 0.0),
}
}
/// `.ase` data, read field by field; reading past its end is an error.
struct AseData<'a>(&'a [u8]);
impl<'a> AseData<'a> {
fn bytes(&mut self, n: usize) -> Result<&'a [u8], String> {
let (head, rest) = self.0.split_at_checked(n).ok_or(ASE_CUT_SHORT)?;
self.0 = rest;
Ok(head)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], String> {
let (head, rest) = self.0.split_first_chunk::<N>().ok_or(ASE_CUT_SHORT)?;
self.0 = rest;
Ok(*head)
}
fn u16(&mut self) -> Result<u16, String> {
self.array().map(u16::from_be_bytes)
}
fn u32(&mut self) -> Result<u32, String> {
self.array().map(u32::from_be_bytes)
}
/// A component; one that isn't a finite number reads as 0.
fn f32(&mut self) -> Result<f32, String> {
let v = f32::from_be_bytes(self.array()?);
Ok(if v.is_finite() { v } else { 0.0 })
}
/// A name up to its terminating zero, trimmed. A block that ends before it has no name.
fn name(&mut self) -> Result<String, String> {
if self.0.is_empty() {
return Ok(String::new());
}
let n = usize::from(self.u16()?);
let (units, _) = self.bytes(n * 2)?.as_chunks::<2>();
let units: Vec<u16> = units.iter().take(ASE_MAX_NAME).map(|u| u16::from_be_bytes(*u)).take_while(|&u| u != 0).collect();
Ok(String::from_utf16_lossy(&units).trim().to_string())
}
}
/// A group's name as a file gives it, trimmed; "Color Group" when it has none.
fn group_name(name: &str) -> String {
let name = name.trim();
if name.is_empty() { "Color Group".into() } else { name.to_string() }
}
/// The swatch and group names a reader has handed out. Swatches and color groups share one set of
/// names, as in a document, and a name already taken gets the first free number ("Black 2").
#[derive(Default)]
struct UniqueNames {
taken: HashSet<String>,
/// The last number tried for each repeated name.
last: HashMap<String, usize>,
}
impl UniqueNames {
fn unique(&mut self, base: String) -> String {
if self.taken.insert(base.clone()) {
return base;
}
// Each pass tries the next number, and only finitely many names are taken.
let n = self.last.entry(base.clone()).or_insert(1);
loop {
*n += 1;
let name = format!("{base} {n}");
if self.taken.insert(name.clone()) {
return name;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -294,6 +500,11 @@ mod tests {
assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]);
assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255)));
assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback");
// Colors and groups share names; an unnamed group is "Color Group".
let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap();
let groups: Vec<(&str, Vec<&str>)> =
lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]);
}
#[test]
@@ -321,4 +532,117 @@ mod tests {
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
assert!(read(&text, "x").is_err(), "CSS is written only");
}
/// A swatch exchange file of `blocks` (type, body).
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut out = b"ASEF\0\x01\0\0".to_vec();
out.extend((blocks.len() as u32).to_be_bytes());
for (kind, body) in blocks {
out.extend(kind.to_be_bytes());
out.extend((body.len() as u32).to_be_bytes());
out.extend(body);
}
out
}
/// A name as `.ase` stores it.
fn ase_name(name: &str) -> Vec<u8> {
let units: Vec<u16> = name.encode_utf16().chain([0]).collect();
let mut out = (units.len() as u16).to_be_bytes().to_vec();
out.extend(units.iter().flat_map(|u| u.to_be_bytes()));
out
}
/// A color block: name, model, components and color type.
fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec<u8>) {
let mut body = ase_name(name);
body.extend(model);
body.extend(values.iter().flat_map(|v| v.to_be_bytes()));
body.extend(kind.to_be_bytes());
(ASE_COLOR, body)
}
#[test]
fn ase_reads_color_models_kinds_and_groups() {
let bytes = ase(&[
ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0),
ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1),
(ASE_GROUP_START, ase_name("Neutrals")),
ase_color("Mist", b"Gray", &[0.75], 2),
ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2),
(ASE_GROUP_END, vec![]),
ase_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2),
]);
assert!(sniff_bytes(&bytes));
let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot };
let expected = SwatchLibrary {
name: "Brand".into(),
swatches: vec![
sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false),
sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true),
sw("Paper", Color::WHITE, false, false),
],
groups: vec![SwatchGroup {
name: "Neutrals".into(),
swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)],
}],
};
assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process");
// A lightness above 1 is L* itself.
let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap();
assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0)));
// Text libraries read through the same call, also when they aren't valid UTF-8.
assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0]));
assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1);
let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n";
assert!(sniff_bytes(latin1));
assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1);
}
#[test]
fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() {
let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2);
untyped.1.truncate(untyped.1.len() - 2);
let bytes = ase(&[
ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2),
ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2),
ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0),
ase_color("Red", b"Gray", &[0.0], 2),
untyped,
ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2),
(0x0042, vec![1, 2, 3]),
(ASE_GROUP_END, vec![]),
(ASE_GROUP_START, ase_name("")),
(ASE_GROUP_START, ase_name("Red")),
ase_color("Red", b"Gray", &[1.0], 2),
]);
let lib = read_bytes(&bytes, "x").unwrap();
let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect();
assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped");
assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0");
assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process");
// A group start ends the open group, and groups and colors share names.
let groups: Vec<(&str, Vec<&str>)> =
lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]);
assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white");
}
#[test]
fn damaged_ase_files_are_errors() {
let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]);
assert_eq!(read_bytes(&good, "x").unwrap().len(), 1);
for n in ASE_SIGNATURE.len()..good.len() {
assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}");
}
let mut v2 = good.clone();
v2[5] = 2;
assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0");
// Too many colors, or too many groups (empty group starts take 6 bytes each).
let colors: Vec<(u16, Vec<u8>)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect();
assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than"));
let groups: Vec<(u16, Vec<u8>)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect();
assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than"));
assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit");
}
}
+3 -1
View File
@@ -527,7 +527,8 @@ impl Lerp {
(NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh,
(NodeKind::Text(_), NodeKind::Text(_))
| (NodeKind::SymbolInstance { .. }, NodeKind::SymbolInstance { .. })
| (NodeKind::Image(_), NodeKind::Image(_)) => Shape::Placed,
| (NodeKind::Image(_), NodeKind::Image(_))
| (NodeKind::PlacedDocument(_), NodeKind::PlacedDocument(_)) => Shape::Placed,
_ => match (node_path(a), node_path(b)) {
(Some((pa, ra, ma)), Some((pb, rb, mb))) => {
Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb), members: ma.zip(mb) }
@@ -586,6 +587,7 @@ impl Lerp {
*o = lerp_affine(*x, *y, t)
}
(NodeKind::Image(o), NodeKind::Image(x), NodeKind::Image(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
(NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
_ => {}
}
n.mask = None;
+19 -6
View File
@@ -44,6 +44,8 @@ impl Hit {
/// Options for hit testing.
#[derive(Clone, Copy, Debug)]
pub struct HitOptions {
/// The isolated construction container, whose retained Shaper sources are hittable.
pub scope: Option<NodeId>,
/// Tolerance in document units (screen tolerance / zoom).
pub tol: f64,
/// Outline mode: only outlines are hittable.
@@ -58,7 +60,7 @@ pub struct HitOptions {
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
}
}
@@ -70,6 +72,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
let mut tops: Vec<NodeId> = vec![];
// Each round leaves out the objects found so far (and everything inside them).
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) {
@@ -131,13 +134,20 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
{
return None;
}
for c in children.iter().rev() {
for (i, c) in children.iter().enumerate().rev() {
if parent.shaper.is_some() {
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
if (i == 0) != construction {
continue;
}
}
// Hidden, locked and template sublayers block clicks like top-level layers do.
if !c.visible || c.locked || c.is_template() || skip(c.id) {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
if !edits_contents(c)
if !(edits_contents(c)
|| c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
@@ -199,9 +209,12 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
}
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
NodeKind::Text(_)
| NodeKind::Image(_)
| NodeKind::PlacedDocument(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
+108
View File
@@ -0,0 +1,108 @@
//! Inline graphics in text ([`crate::TextRun::inline`]): resolving each inline symbol's art bounds
//! from the document's symbols, which the text layout needs to size and place it.
use std::borrow::Cow;
use vectorcraft_geom::{Affine, Rect};
use crate::{Document, NodeId, NodeKind, TextObject};
/// The document key under which the app records each symbol's natural size (`{name: [w, h]}`).
pub const SYMBOL_SIZES: &str = "symbolSizes";
/// Half the side of the square symbol art is normalised to.
pub const SYMBOL_HALF: f64 = 10.0;
/// Usable art bounds: finite, non-degenerate.
fn usable(r: Option<Rect>) -> Option<Rect> {
r.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() >= 0.0 && r.height() > 1e-9)
}
impl Document {
/// Symbol space → the symbol's art at its natural size. Symbols made in the app keep their
/// art in a 20 × 20 square around the origin and their size as drawn in the document's
/// `symbolSizes` (an instance is placed with that scale); other symbols are as they are.
pub fn symbol_natural_xf(&self, name: &str) -> Affine {
let size = self.unknown.get(SYMBOL_SIZES).and_then(|m| m.get(name)).and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)));
match size {
Some((w, h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => {
Affine::scale_non_uniform(w / (2.0 * SYMBOL_HALF), h / (2.0 * SYMBOL_HALF))
}
_ => Affine::IDENTITY,
}
}
/// Visual bounds of the art of the symbol named `name` at its natural size (see
/// [`Self::symbol_natural_xf`]), if it exists and has a usable extent.
pub fn symbol_art_bounds(&self, name: &str) -> Option<Rect> {
let s = self.symbols.iter().find(|s| s.name == name)?;
let b = usable(s.art.visual_bounds().or_else(|| s.art.geometric_bounds()))?;
usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b)))
}
/// Does `t` have inline graphics whose resolved bounds differ from the document's symbols?
fn inline_stale(&self, t: &TextObject) -> bool {
t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol))
}
/// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed
/// when nothing changes). Renderers and exporters lay text out through this, so they never
/// depend on the cached bounds being current.
pub fn inline_resolved<'a>(&self, t: &'a TextObject) -> Cow<'a, TextObject> {
if !self.inline_stale(t) {
return Cow::Borrowed(t);
}
let mut t = t.clone();
self.resolve_text_inline(&mut t);
Cow::Owned(t)
}
/// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's
/// cached layout bounds are then cleared).
pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool {
let mut changed = false;
for r in &mut t.runs {
if let Some(a) = &mut r.inline {
let b = self.symbol_art_bounds(&a.symbol);
if a.bounds != b {
a.bounds = b;
changed = true;
}
}
}
if changed {
t.cached_bounds = None;
}
changed
}
/// Resolve the inline graphics of every text object in the layers (after opening a document
/// or changing its symbols). Returns the ids of the texts that changed: their cached layout
/// bounds were cleared and need recomputing.
pub fn resolve_inline_art(&mut self) -> Vec<NodeId> {
let mut stale = vec![];
self.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| r.inline.is_some())
&& self.inline_stale(t)
{
stale.push(n.id);
}
});
let mut out = vec![];
for id in stale {
let Some(mut t) = self.node(id).and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.clone()),
_ => None,
}) else {
continue;
};
if self.resolve_text_inline(&mut t)
&& let Some(n) = self.node_mut(id)
{
n.kind = NodeKind::Text(t);
out.push(id);
}
}
out
}
}
+14 -2
View File
@@ -14,6 +14,8 @@ pub mod corners;
pub mod graph;
pub mod hit;
pub mod inks;
mod inline;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub mod links;
pub mod live;
pub mod marks;
@@ -24,6 +26,7 @@ pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub mod placed_document;
pub mod profiles;
pub mod puppet;
pub mod range;
@@ -32,6 +35,7 @@ mod reach;
pub mod recolor;
pub mod selection;
pub mod setup;
pub mod shaper;
pub mod slices;
pub mod style_libs;
pub mod swatches;
@@ -72,6 +76,7 @@ pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, N
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
pub use placed_document::PlacedDocument;
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
@@ -80,8 +85,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::{
AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign,
PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, LeadingModel, Mojikumi,
ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
VerticalAlign, WrapShape,
};
pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom;
@@ -604,6 +610,11 @@ pub struct Document {
/// save time only, so changing the view never marks the document modified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_view: Option<SavedView>,
/// The layers, sublayers and groups open in the Layers panel when the document was saved; they
/// reopen that way. `None`: the default, only the top-level layers open. Written at save time
/// only, like [`Document::last_view`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layers_open: Option<Vec<NodeId>>,
/// Edit → Assign Profile: the profiles the document is tagged with (files before format v3
/// kept them in `unknown`, see [`Document::migrate_color_profiles`]).
#[serde(default, skip_serializing_if = "ColorProfiles::is_empty")]
@@ -696,6 +707,7 @@ impl Document {
metadata: DocMetadata::default(),
raster_effects: RasterEffectsSettings::default(),
last_view: None,
layers_open: None,
color_profiles: ColorProfiles::default(),
export_settings: Default::default(),
extra: Default::default(),
+3 -1
View File
@@ -137,7 +137,7 @@ impl Document {
}
}
/// The keys of image blobs only linked images show: what a save writes as their preview.
/// The keys of image blobs only linked images (or placed documents, always linked) show: what a save writes as their preview.
pub fn linked_only_images(&self) -> BTreeSet<String> {
if self.images.values().all(|b| b.proxy.is_none()) {
return BTreeSet::new();
@@ -152,6 +152,8 @@ impl Document {
u.1 = true;
}
});
// A placed document saves its file's preview the same way.
self.visit_placed(|_, p| uses.entry(p.key.as_str()).or_default().0 = true);
uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect()
}
}
+4 -1
View File
@@ -1447,6 +1447,8 @@ fn map_node(n: &Node, w: &Warper) -> Node {
let im = im.clone();
return warp_image(&out, &im, w);
}
// Its art is plain, so it warps at this depth.
NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w),
}
out
}
@@ -1607,7 +1609,7 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
/// Is this one of the live kinds?
pub fn is_live(n: &Node) -> bool {
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_))
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_))
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
@@ -1641,6 +1643,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks),
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
NodeKind::Repeat(r) => r.expand(),
NodeKind::PlacedDocument(p) => p.art(n),
_ => vec![n.clone()],
}
}
+25 -2
View File
@@ -423,6 +423,9 @@ pub enum NodeKind {
Mesh(GradientMesh),
/// Live Repeat (radial / grid / mirror) of source art.
Repeat(RepeatSpec),
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
/// [`crate::placed_document`]).
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
}
fn yes() -> bool {
@@ -469,6 +472,9 @@ pub struct Node {
/// Graph object: the group's children are generated from this spec (Object → Graph).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// Editable Shaper composition; the original art is retained in its source child.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
pub kind: NodeKind,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
/// that style's look: editing its appearance or transparency breaks the link.
@@ -533,6 +539,7 @@ impl Node {
trace: None,
wrap: None,
graph: None,
shaper: None,
kind,
graphic_style: None,
attrs: None,
@@ -642,6 +649,7 @@ impl Node {
NodeKind::Envelope { .. } => "Envelope",
NodeKind::Mesh(_) => "Mesh",
NodeKind::Repeat(r) => r.kind.label(),
NodeKind::PlacedDocument(_) => "Placed Document",
}
}
/// Name shown in the Layers panel: explicit name or `<Kind>`.
@@ -703,6 +711,7 @@ impl Node {
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.skip(usize::from(self.shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
NodeKind::Text(t) => self.projected(t.bounds()),
@@ -715,6 +724,7 @@ impl Node {
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
}
}
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
@@ -733,7 +743,11 @@ impl Node {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o))
children
.iter()
.skip(usize::from(self.shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
.map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
@@ -810,6 +824,7 @@ impl Node {
}
}
NodeKind::Image(im) => im.xf = a * im.xf,
NodeKind::PlacedDocument(p) => p.xf = a * p.xf,
NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf,
NodeKind::Blend { children, spec } => {
for c in children.iter_mut() {
@@ -959,7 +974,7 @@ impl Node {
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter().filter(|c| c.visible) {
for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) {
c.push_clip_shapes(text, out);
}
}
@@ -972,6 +987,10 @@ impl Node {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
out.push((frame.to_bezpath(), FillRule::NonZero));
}
NodeKind::PlacedDocument(p) => {
let frame = shapes::rectangle(p.natural()).transformed(p.xf);
out.push((frame.to_bezpath(), FillRule::NonZero));
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
}
@@ -1204,6 +1223,10 @@ pub struct ObjectAttributes {
/// The note shown in the Attributes panel.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
/// The object's own data, in order: SVG's `data-*` attributes (`data-pivot="100,180"` is
/// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub data: Vec<(String, String)>,
}
/// The Attributes panel's Image Map shapes.
+684
View File
@@ -0,0 +1,684 @@
//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]).
//!
//! The object keeps the link to the file and the file's bytes, stored like image data in
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
//! document shows correctly without the file. The art it shows is read from those bytes and never
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
//! expand it like a live object ([`crate::live::expanded`]).
//!
//! The file is read by the native format's loader, which lives above this crate and registers
//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting,
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
//! of the document, so they never meet the user's or show in panels. Placed documents inside a
//! placed document are read the same way, up to [`MAX_DEPTH`] deep (documents that place each
//! other stop there, showing their previews).
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_color::Paint;
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
/// A placed document: an artboard of another VectorCraft file, shown as one locked object.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacedDocument {
/// The file (its `page` is the artboard shown, 1-based).
pub link: LinkInfo,
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
pub key: String,
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bounding: bool,
/// The artboard's size (or the art's) at 100% (pt).
pub width: f64,
pub height: f64,
/// Maps the box (0..width, 0..height) into the document.
pub xf: Affine,
/// Links panel → Placement Options: how the file read again takes this object's place.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub placement: PlacementOptions,
}
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and
/// its preview.
pub fn key_for(hash: &str, page: u32, bounding: bool) -> String {
format!("{hash}-{page}{}", if bounding { "b" } else { "" })
}
impl PlacedDocument {
/// The artboard shown (1-based).
pub fn page(&self) -> u32 {
self.link.page.unwrap_or(1)
}
/// The box at 100%.
pub fn natural(&self) -> Rect {
Rect::new(0.0, 0.0, self.width, self.height)
}
/// The four corners of the box in the document.
pub fn corners(&self) -> [vectorcraft_geom::Point; 4] {
let r = self.natural();
[(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.xf * vectorcraft_geom::Point::new(x, y))
}
/// The box in the document (axis-aligned).
pub fn bounds(&self) -> Rect {
self.xf.transform_rect_bbox(self.natural())
}
/// The transform from the file's coordinates (its artboard or art bounds `frame`) to the
/// document; `None` when either size is empty.
pub fn art_xf(&self, frame: Rect) -> Option<Affine> {
let ok = self.width > 0.0 && self.height > 0.0 && frame.width() > 0.0 && frame.height() > 0.0;
ok.then(|| {
self.xf * Affine::scale_non_uniform(self.width / frame.width(), self.height / frame.height()) * Affine::translate((-frame.x0, -frame.y0))
})
}
/// The art the object shows, as plain objects with `n`'s id: one group, its resources named
/// as [`Document::with_placed_art`] adds them (nothing until that has read it).
pub fn art(&self, n: &Node) -> Vec<Node> {
let prefix = prefix(&self.key);
read_cached(self).and_then(|e| self.art_node(n, &e, &prefix)).into_iter().collect()
}
/// Read `e`'s art placed as this object `n` shows it (`n`'s id), its resources renamed with
/// `prefix`.
fn art_node(&self, n: &Node, e: &ReadDocument, prefix: &str) -> Option<Node> {
let m = self.art_xf(e.frame)?;
let mut g = (*e.art).clone();
rename(&mut g, prefix);
g.transform(m, true);
each_mut(&mut g, &mut |c| crate::pattern::transform_pattern_paints(c, m));
g.id = n.id;
Some(g)
}
/// An image object showing preview `bytes` (stored under this object's key) in this object's
/// box: what it shows and outputs as when the file can't be read.
pub fn preview_image(&self, bytes: &[u8]) -> Option<ImageObject> {
let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?;
let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64);
Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() })
}
}
/// The names the resources of the art stored under `key` take in a prepared document.
fn prefix(key: &str) -> String {
format!("{RESOURCE_PREFIX}{key}/")
}
/// `n` and every descendant, opacity-mask art included.
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
if let Some(m) = &n.mask {
walk_all(&m.art, f);
}
for c in n.children().into_iter().flatten() {
walk_all(c, f);
}
}
/// The roots of `d`'s art: layers, symbol definitions and pattern swatches.
fn roots(d: &Document) -> impl Iterator<Item = &Arc<Node>> {
d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| &p.art))
}
fn roots_mut(d: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
}
impl Document {
/// Visit every placed document: in the layers, then in symbol definitions and pattern
/// swatches, opacity-mask art included.
pub fn visit_placed<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a PlacedDocument)) {
for root in roots(self) {
walk_all(root, &mut |n| {
if let NodeKind::PlacedDocument(p) = &n.kind {
f(n, p);
}
});
}
}
/// Does the document hold a placed document anywhere?
pub fn has_placed(&self) -> bool {
let mut any = false;
self.visit_placed(|_, _| any = true);
any
}
/// Every placed document (those in the art they show too) replaced by a plain group of that
/// art, for apps that don't know them: call it on [`Self::with_placed_art`]'s copy. Only the
/// subtrees holding one are copied.
pub fn placed_as_groups(&mut self) {
fn holds(n: &Node) -> bool {
let mut hit = false;
walk_all(n, &mut |c| hit |= matches!(c.kind, NodeKind::PlacedDocument(_)));
hit
}
fn update(n: &mut Arc<Node>) {
if !holds(n) {
return;
}
if matches!(n.kind, NodeKind::PlacedDocument(_)) {
let mut g = crate::live::expanded_group(n, None);
g.appearance = n.appearance.clone();
*n = Arc::new(g);
}
// The art shown may hold placed documents too.
let n = Arc::make_mut(n);
if let Some(m) = &mut n.mask {
update(&mut m.art);
}
for c in n.children_mut().into_iter().flatten() {
update(c);
}
}
for root in roots_mut(self) {
update(root);
}
}
/// Placed documents whose key is in `keys` replaced by images of their previews (for output
/// when their files can't be read).
pub fn placed_as_previews(&mut self, keys: &BTreeSet<String>) {
fn update(n: &mut Arc<Node>, keys: &BTreeSet<String>, images: &std::collections::BTreeMap<String, ImageBlob>) {
let mut hit = false;
walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::PlacedDocument(p) if keys.contains(&p.key)));
if !hit {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::PlacedDocument(p) = &n.kind
&& keys.contains(&p.key)
{
if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) {
n.kind = NodeKind::Image(im);
}
return;
}
if let Some(m) = &mut n.mask {
update(&mut m.art, keys, images);
}
for c in n.children_mut().into_iter().flatten() {
update(c, keys, images);
}
}
let images = self.images.clone();
for root in roots_mut(self) {
update(root, keys, &images);
}
}
}
// ---------- previews and the files' bytes ----------
/// A placed document's preview: this many pixels per point of its box…
pub const PREVIEW_PX_PER_PT: f64 = 1.0;
/// …and at most this many on its longer side.
pub const PREVIEW_MAX: u32 = 1024;
/// How deep placed documents inside placed documents are read; deeper ones show their previews.
pub const MAX_DEPTH: usize = 8;
/// Reads `p`'s file again (`None`: not found, or changed since it was read). The engine registers
/// one with [`set_file_reader`].
pub type FileReader = fn(&PlacedDocument) -> Option<Vec<u8>>;
static FILE_READER: OnceLock<FileReader> = OnceLock::new();
/// Register how files are read again (the first registration stays).
pub fn set_file_reader(f: FileReader) {
// Already set: the same engine, registered by another session.
let _ = FILE_READER.set(f);
}
/// The most bytes of files read again kept.
const FULL_BYTES: usize = 512 << 20;
/// Files read again, by key, the most recently read last.
type FullFiles = Vec<(String, Arc<Vec<u8>>)>;
static FULL: Mutex<FullFiles> = Mutex::new(Vec::new());
fn full() -> MutexGuard<'static, FullFiles> {
// Entries are only pushed and removed: a panic while held leaves it whole.
FULL.lock().unwrap_or_else(PoisonError::into_inner)
}
/// The file's bytes when they are at hand: stored (`blob` isn't just the preview) or read again
/// already. Never reads it.
pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec<u8>>> {
if !blob.is_proxy() {
return Some(blob.bytes.clone());
}
full().iter().rev().find(|(k, _)| *k == p.key).map(|(_, b)| b.clone())
}
/// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None`
/// when only the preview can be had.
pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec<u8>>> {
if let Some(b) = full_bytes_cached(p, blob) {
return Some(b);
}
let b = Arc::new(FILE_READER.get()?(p)?);
let mut c = full();
c.push((p.key.clone(), b.clone()));
let mut total: usize = c.iter().map(|(_, b)| b.len()).sum();
while total > FULL_BYTES && c.len() > 1 {
total = total.saturating_sub(c.remove(0).1.len());
}
Some(b)
}
/// The size in pixels of image bytes `bytes` (a preview), read from its header.
pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
}
// ---------- reading files ----------
/// The prefix of the names the resources of placed documents' art take in a prepared document:
/// `placed:<key>/<name>`.
pub const RESOURCE_PREFIX: &str = "placed:";
/// Reads a placed document's file (its bytes) into what it shows: a document holding the
/// resources (no layers), the art as one group in the file's coordinates, and the frame shown
/// (the artboard, or the art's bounds). The native format lives above this crate, so the engine
/// registers one with [`set_loader`].
pub type Loader = fn(&PlacedDocument, &[u8]) -> Option<(Document, Node, Rect)>;
static LOADER: OnceLock<Loader> = OnceLock::new();
/// Register how files are read (the first registration stays).
pub fn set_loader(l: Loader) {
// Already set: the same loader, registered by another session.
let _ = LOADER.set(l);
}
/// The most files kept read whatever their size.
const CACHE_SIZE: usize = 64;
/// Past [`CACHE_SIZE`], the most bytes of files kept read (their own sizes, standing for what
/// reading them holds).
const CACHE_BYTES: usize = 256 << 20;
/// A placed document's file read: the art it shows, ready to draw with its own resources.
pub struct ReadDocument {
key: String,
/// Read from the preview, not the file.
preview: bool,
/// The bytes read (what tells a cached read is still the one shown).
bytes: Arc<Vec<u8>>,
/// The artboard (or art bounds) shown: what the object's box shows.
pub frame: Rect,
/// The art as one group, in the file's coordinates. Its images, symbols and patterns are
/// `doc`'s, under their own names.
pub art: Arc<Node>,
/// The file's resources and setup (no layers: the art is `art`).
pub doc: Arc<Document>,
}
struct Entry {
e: Arc<ReadDocument>,
used: u64,
}
#[derive(Default)]
struct Cache {
entries: std::collections::HashMap<(String, bool), Entry>,
clock: u64,
}
static CACHE: std::sync::LazyLock<Mutex<Cache>> = std::sync::LazyLock::new(Default::default);
fn cache() -> MutexGuard<'static, Cache> {
// A panic while the cache was held leaves it whole (entries are only inserted and removed).
CACHE.lock().unwrap_or_else(PoisonError::into_inner)
}
impl Cache {
/// The read of `key` (its preview's when `preview`; made from `bytes` when given), marked used.
fn get(&mut self, key: &str, preview: bool, bytes: Option<&Arc<Vec<u8>>>) -> Option<Arc<ReadDocument>> {
self.clock += 1;
let clock = self.clock;
let e = self.entries.get_mut(&(key.to_string(), preview))?;
if bytes.is_some_and(|b| !Arc::ptr_eq(&e.e.bytes, b)) {
return None;
}
e.used = clock;
Some(e.e.clone())
}
fn insert(&mut self, e: Arc<ReadDocument>) {
self.clock += 1;
self.entries.insert((e.key.clone(), e.preview), Entry { e, used: self.clock });
// The least recently used go while over both limits.
let mut total: usize = self.entries.values().map(|x| x.e.bytes.len()).sum();
while self.entries.len() > CACHE_SIZE && total > CACHE_BYTES {
let Some(k) = self.entries.iter().min_by_key(|(_, x)| x.used).map(|(k, _)| k.clone()) else { break };
if let Some(x) = self.entries.remove(&k) {
total = total.saturating_sub(x.e.bytes.len());
}
}
}
}
/// `p`'s file (`bytes`) read, from the cache when it was read already: what the renderer draws a
/// placed document from. `None` when it can't be read (or no loader is registered).
pub fn read(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocument>> {
if let Some(e) = cache().get(&p.key, false, Some(bytes)) {
return Some(e);
}
let loader = LOADER.get()?;
let (doc, art, frame) = loader(p, bytes)?;
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
cache().insert(e.clone());
Some(e)
}
/// `p`'s preview (`bytes`) read as art: one image filling the box.
fn read_preview(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocument>> {
if let Some(e) = cache().get(&p.key, true, Some(bytes)) {
return Some(e);
}
let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?;
let frame = p.natural();
let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0));
doc.layers.clear();
doc.swatches.clear();
let mime = if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { "image/png" };
doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None });
let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64);
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() };
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(im)))], clip: false });
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
cache().insert(e.clone());
Some(e)
}
/// `p`'s file as read already (else its preview's), without reading it.
pub fn read_cached(p: &PlacedDocument) -> Option<Arc<ReadDocument>> {
let mut c = cache();
c.get(&p.key, false, None).or_else(|| c.get(&p.key, true, None))
}
/// Keep `read` (what [`Loader`] gives), just read from `bytes` for `p` (when it was placed), so
/// drawing it doesn't read it again.
pub fn prime(p: &PlacedDocument, bytes: Arc<Vec<u8>>, (doc, art, frame): (Document, Node, Rect)) {
cache().insert(Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes, frame, art: Arc::new(art), doc: Arc::new(doc) }));
}
/// The id the read art's own nodes take (their object's, once expanded).
fn art_id() -> crate::NodeId {
crate::NodeId(0)
}
/// Visit `n` and every descendant (opacity-mask art included) mutably.
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
f(n);
if let Some(m) = &mut n.mask {
each_mut(Arc::make_mut(&mut m.art), f);
}
for c in n.children_mut().into_iter().flatten() {
each_mut(Arc::make_mut(c), f);
}
}
/// `n`'s references to images, symbols, patterns and placed files prefixed with `prefix`. Colours
/// keep their swatch links (a spot colour stays a plate, one with the parent's spot of that name).
fn rename(n: &mut Node, prefix: &str) {
let paint = |p: &mut Paint| {
if let Paint::Pattern { pattern, .. } = p {
*pattern = format!("{prefix}{pattern}");
}
};
each_mut(n, &mut |n| {
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(f) => paint(&mut f.paint),
AppearanceItem::Stroke(s) => paint(&mut s.paint),
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
paint(&mut r.style.fill);
paint(&mut r.style.stroke);
}
}
NodeKind::Image(im) => im.key = format!("{prefix}{}", im.key),
NodeKind::PlacedDocument(p) => p.key = format!("{prefix}{}", p.key),
NodeKind::SymbolInstance { symbol, .. } => *symbol = format!("{prefix}{symbol}"),
_ => {}
}
});
}
impl Document {
/// This document ready to export its placed documents: their files read (the file read again
/// when only the preview is stored, else the preview), and the resources their art uses added
/// under names of their own ([`RESOURCE_PREFIX`]); the same for the placed documents in that
/// art, up to [`MAX_DEPTH`] deep. Borrowed when there is none. (The renderer draws them from
/// [`read`] instead.)
pub fn with_placed_art(&self) -> Cow<'_, Document> {
let mut queue: Vec<(PlacedDocument, usize)> = vec![];
let mut seen = BTreeSet::new();
self.visit_placed(|_, p| {
if seen.insert(p.key.clone()) {
queue.push((p.clone(), 0));
}
});
if queue.is_empty() {
return Cow::Borrowed(self);
}
let mut d = self.clone();
let mut found: Vec<Arc<ReadDocument>> = vec![];
while let Some((p, depth)) = queue.pop() {
let Some(blob) = d.images.get(&p.key).cloned() else { continue };
let full = if depth < MAX_DEPTH { full_bytes(&p, &blob) } else { None };
if full.and_then(|b| read(&p, &b)).is_none() {
let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() };
if let Some(bytes) = preview {
read_preview(&p, &bytes);
}
}
// What the art (`PlacedDocument::art`) will show.
let Some(e) = read_cached(&p) else { continue };
let prefix = prefix(&p.key);
add_resources(&mut d, &e.doc, &prefix);
// The placed documents in that art, under their names here.
let inner = std::iter::once(&e.art).chain(e.doc.symbols.iter().map(|s| &s.art)).chain(e.doc.patterns.iter().flat_map(|p| &p.art));
for root in inner {
walk_all(root, &mut |n| {
if let NodeKind::PlacedDocument(q) = &n.kind {
let key = format!("{prefix}{}", q.key);
if seen.insert(key.clone()) {
queue.push((PlacedDocument { key, ..(**q).clone() }, depth + 1));
}
}
});
}
found.push(e);
}
// Read last, so all stay read while the art is made (see `PlacedDocument::art`).
{
let mut c = cache();
for e in &found {
c.get(&e.key, e.preview, None);
}
}
Cow::Owned(d)
}
/// Drop the resources [`Self::with_placed_art`] added under its names (what a save leaves out).
pub fn drop_placed_resources(&mut self) {
self.symbols.retain(|s| !s.name.starts_with(RESOURCE_PREFIX));
self.patterns.retain(|p| !p.name.starts_with(RESOURCE_PREFIX));
self.images.retain(|k, _| !k.starts_with(RESOURCE_PREFIX));
}
}
/// The resources of `src` (a read file's) added to `d` under `prefix`: symbols, patterns, images;
/// swatches by name, when `d` has none of that name.
fn add_resources(d: &mut Document, src: &Document, prefix: &str) {
for s in &src.symbols {
let name = format!("{prefix}{}", s.name);
if !d.symbols.iter().any(|x| x.name == name) {
let mut s = s.clone();
rename(Arc::make_mut(&mut s.art), prefix);
d.symbols.push(Symbol { name, ..s });
}
}
for p in &src.patterns {
let name = format!("{prefix}{}", p.name);
if d.pattern(&name).is_none() {
let mut p = p.clone();
p.art.iter_mut().for_each(|a| rename(Arc::make_mut(a), prefix));
d.patterns.push(PatternDef { name, ..p });
}
}
for (k, b) in &src.images {
d.images.entry(format!("{prefix}{k}")).or_insert_with(|| b.clone());
}
for w in &src.swatches {
if d.swatch(&w.name).is_none() {
d.swatches.push(w.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NodeId;
fn placed(key: &str) -> PlacedDocument {
PlacedDocument {
link: LinkInfo::new("/art/logo.vectorcraft"),
key: key.into(),
bounding: false,
width: 80.0,
height: 40.0,
xf: Affine::translate((10.0, 10.0)),
placement: Default::default(),
}
}
/// Whatever the bytes: a 40×20 artboard showing a symbol instance and, when the bytes say
/// `nest`, a placed document of key `inner`.
fn stub_loader(_: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
let mut d = Document::new(40.0, 20.0);
d.layers.clear();
let art = Node::new(
NodeId(50),
NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))];
if bytes.starts_with(b"nest") {
d.images.insert("inner".into(), ImageBlob::new("application/json", b"leaf".to_vec()));
let inner = PlacedDocument { key: "inner".into(), width: 10.0, height: 10.0, xf: Affine::IDENTITY, ..placed("inner") };
children.push(Arc::new(Node::new(NodeId(52), NodeKind::PlacedDocument(Box::new(inner)))));
}
Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0)))
}
#[test]
fn round_trips_through_json() {
let n = Node::new(NodeId(3), NodeKind::PlacedDocument(Box::new(placed("doc1-1"))));
let v = serde_json::to_value(&n).unwrap();
assert_eq!(v["kind"]["type"], "placeddocument");
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
let back: Node = serde_json::from_value(v).unwrap();
assert_eq!(back, n);
assert_eq!(key_for("img00", 2, true), "img00-2b");
}
#[test]
fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() {
set_loader(stub_loader);
let mut d = Document::new(200.0, 200.0);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) });
d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec()));
let p = placed("doc1-1");
let layer = d.layers[0].id;
d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(p.clone())))).unwrap();
assert!(d.has_placed());
let prepared = d.with_placed_art();
let names: Vec<&str> = prepared.symbols.iter().map(|s| s.name.as_str()).collect();
// The placed document inside is read too, its resources under its own names.
assert_eq!(names, ["Star", "placed:doc1-1/Star", "placed:placed:doc1-1/inner/Star"]);
assert!(prepared.images.contains_key("placed:doc1-1/pic"));
assert_eq!(d.symbols.len(), 1, "the document itself is unchanged");
// The art: a group of the drawing, its resources renamed.
let n = d.node(NodeId(7)).unwrap();
let art = p.art(n);
let [g] = &art[..] else { panic!("{art:?}") };
let mut refs = vec![];
g.walk(&mut |c| match &c.kind {
NodeKind::SymbolInstance { symbol, .. } => refs.push(symbol.clone()),
NodeKind::PlacedDocument(q) => {
// Its 10×10 box at the file's origin, scaled ×2 into the box at (10, 10).
assert_eq!(q.bounds(), Rect::new(10.0, 10.0, 30.0, 30.0));
refs.push(q.key.clone())
}
_ => {}
});
assert_eq!(refs, ["placed:doc1-1/Star", "placed:doc1-1/inner"]);
// For older apps: plain groups all the way down.
let mut older = prepared.clone().into_owned();
older.placed_as_groups();
assert!(!older.has_placed());
let mut saved = prepared.into_owned();
saved.drop_placed_resources();
assert_eq!((saved.symbols.len(), saved.images.len()), (1, 1));
}
#[test]
fn more_files_than_the_cache_holds_all_draw() {
set_loader(stub_loader);
let mut d = Document::new(200.0, 200.0);
let layer = d.layers[0].id;
let n = CACHE_SIZE + 10;
for i in 0..n {
let key = format!("many{i}-1");
d.images.insert(key.clone(), ImageBlob::new("application/json", format!("doc {i}").into_bytes()));
d.insert(Some(layer), i, Node::new(NodeId(100 + i as u64), NodeKind::PlacedDocument(Box::new(placed(&key))))).unwrap();
}
let _prepared = d.with_placed_art();
let mut empty = 0;
d.visit_placed(|n, p| empty += usize::from(p.art(n).is_empty()));
assert_eq!(empty, 0, "every file of the document is kept read");
}
#[test]
fn a_file_that_cant_be_read_shows_its_preview() {
set_loader(stub_loader);
let mut png = vec![];
image::RgbaImage::from_pixel(8, 4, image::Rgba([0, 200, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut blob = ImageBlob::new("image/png", png);
blob.proxy = Some(blob.bytes.clone());
let mut d = Document::new(200.0, 200.0);
d.images.insert("gone-1".into(), blob);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(placed("gone-1"))))).unwrap();
// No file reader: only the preview can be had.
let prepared = d.with_placed_art();
assert!(prepared.images.contains_key("placed:gone-1/preview"));
let n = d.node(NodeId(7)).unwrap();
let NodeKind::PlacedDocument(p) = &n.kind else { panic!("not a placed document") };
let art = p.art(n);
assert_eq!(art.first().and_then(Node::geometric_bounds), Some(Rect::new(10.0, 10.0, 90.0, 50.0)));
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Persistent Shaper edits over retained source paths. Source indices (not document ids) keep
//! recipes valid when a group is copied. Selector anchors use source-relative bounding boxes,
//! so the intended face or stroke follows source art when it moves or resizes.
use crate::Appearance;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Point;
pub const SOURCES: &str = "Shaper Sources";
pub const FILL_PREFIX: &str = "Shaper Fill ";
pub const STROKE_PREFIX: &str = "Shaper Stroke ";
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ShaperSpec {
pub faces: Vec<FaceEdit>,
pub edges: Vec<EdgeEdit>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceSelector {
pub sources: Vec<usize>,
pub anchor: Point,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceEdit {
pub selector: FaceSelector,
pub erase: bool,
pub erase_edges: bool,
pub paint_source: Option<usize>,
pub merge: Option<u64>,
pub paint: Option<Appearance>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EdgeEdit {
pub source: usize,
pub sides: [Vec<usize>; 2],
pub anchor: Point,
pub erase: bool,
pub paint: Option<Appearance>,
}
+355 -4
View File
@@ -93,8 +93,9 @@ pub struct CharStyle {
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
/// baseline, or at the top (right, in vertical type), centre or bottom (left) of the ideographic
/// em boxes.
/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic
/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the
/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CharAlign {
@@ -103,6 +104,8 @@ pub enum CharAlign {
EmBoxTop,
EmBoxCenter,
EmBoxBottom,
IcfTop,
IcfBottom,
}
/// Superscript or subscript proportions in percent of the font size (Document Setup → Type).
@@ -285,6 +288,24 @@ fn default_align_on() -> char {
/// Distance between default tab stops when no explicit stop applies (½ inch).
pub const DEFAULT_TAB_INTERVAL: f64 = 36.0;
/// Paragraph composer (Paragraph panel menu): how lines are broken.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Composer {
/// Break each line as soon as it is full.
SingleLine,
/// Total fit over the whole paragraph (Illustrator's default): justified lines get even word
/// spacing, ragged lines an even rag.
#[default]
EveryLine,
}
impl Composer {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
@@ -299,6 +320,8 @@ pub struct ParaStyle {
pub space_before: f64,
pub space_after: f64,
pub hyphenate: bool,
/// Line breaking: Single-line or Every-line Composer.
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
pub tabs: Vec<TabStop>,
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
@@ -341,6 +364,8 @@ struct ParaStyleFile {
space_after: f64,
#[serde(default)]
hyphenate: bool,
#[serde(default, skip_serializing_if = "Composer::is_default")]
composer: Composer,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tabs: Vec<TabStop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -370,6 +395,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
composer,
tabs,
style_name,
mojikumi,
@@ -387,6 +413,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
composer,
tabs,
style_name,
mojikumi,
@@ -407,6 +434,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
composer,
tabs,
style_name,
mojikumi,
@@ -423,6 +451,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
composer,
tabs,
style_name,
mojikumi,
@@ -506,6 +535,34 @@ pub enum FirstBaseline {
Fixed,
}
/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VerticalAlign {
/// Lines start at the top of the cell (the default).
#[default]
Top,
/// The block of lines is centred in the cell.
Center,
/// The last line's descent touches the cell bottom.
Bottom,
/// The first line stays at the top, the last one moves to the bottom and the space left over
/// is shared equally between the lines (no paragraph spacing limit).
Justify,
}
impl VerticalAlign {
pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify];
pub fn id(self) -> &'static str {
match self {
VerticalAlign::Top => "top",
VerticalAlign::Center => "center",
VerticalAlign::Bottom => "bottom",
VerticalAlign::Justify => "justify",
}
}
}
/// Text Wrap Options of a wrap object (Object → Text Wrap).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -608,11 +665,85 @@ pub struct AreaOptions {
pub first_baseline: FirstBaseline,
/// Minimum first-baseline offset in points.
pub first_baseline_min: f64,
/// Vertical alignment of the lines in each row/column.
pub vertical_align: VerticalAlign,
/// How the frame and its text fit each other (Auto Size, Shrink Text to Fit).
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub fit: AreaFit,
}
impl Default for AreaOptions {
fn default() -> Self {
Self { rows: 1, columns: 1, gutter: 18.0, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0 }
Self {
rows: 1,
columns: 1,
gutter: 18.0,
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
vertical_align: VerticalAlign::Top,
fit: AreaFit::None,
}
}
}
/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or
/// `{"shrinkText": {"minPercent": 50}}`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AreaFit {
/// The frame keeps its size; text that doesn't fit overflows.
#[default]
None,
/// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after
/// each edit, for rectangular frames of horizontal type in one row.
AutoHeight,
/// Text that overflows is scaled down (size, leading and baseline shift; not paragraph
/// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time.
ShrinkText {
#[serde(rename = "minPercent", default = "AreaFit::default_min_percent")]
min_percent: f64,
},
}
impl AreaFit {
/// The smallest Shrink Text to Fit percentage allowed.
pub const MIN_PERCENT: f64 = 10.0;
/// The Shrink Text to Fit percentage new settings start with.
pub const DEFAULT_MIN_PERCENT: f64 = 50.0;
fn default_min_percent() -> f64 {
Self::DEFAULT_MIN_PERCENT
}
/// Its id: `none`, `autoHeight` or `shrinkText`.
pub fn id(self) -> &'static str {
match self {
AreaFit::None => "none",
AreaFit::AutoHeight => "autoHeight",
AreaFit::ShrinkText { .. } => "shrinkText",
}
}
/// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent`
/// for Shrink Text (clamped to 10..100; a non-finite value gives the default).
pub fn parse(id: &str, min_percent: Option<f64>) -> Option<Self> {
match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"none" | "off" => Some(AreaFit::None),
"autoheight" | "autosize" => Some(AreaFit::AutoHeight),
"shrinktext" | "shrinktexttofit" | "shrink" => {
Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) })
}
_ => None,
}
}
/// A Shrink Text minimum percentage within 10..100 (the default when not finite).
pub fn clamp_percent(p: f64) -> f64 {
if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT }
}
/// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text.
pub fn min_scale(self) -> Option<f64> {
match self {
AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0),
_ => None,
}
}
}
@@ -630,6 +761,70 @@ pub struct TextStyleDef {
pub struct TextRun {
pub text: String,
pub style: CharStyle,
/// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows
/// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets
/// the size and tracking; fills and strokes don't apply to the art.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inline: Option<InlineArt>,
}
impl TextRun {
/// A plain text run.
pub fn new(text: impl Into<String>, style: CharStyle) -> Self {
Self { text: text.into(), style, inline: None }
}
/// An inline graphic run showing `art`.
pub fn inline(art: InlineArt, style: CharStyle) -> Self {
Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) }
}
}
/// The object replacement character: the plain text of an inline graphic run.
pub const INLINE_CHAR: char = '\u{FFFC}';
/// Largest inline graphic scale (× the run's size) accepted from input.
pub const INLINE_MAX_SCALE: f64 = 100.0;
/// A document symbol placed inline in text ([`TextRun::inline`]).
///
/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's
/// font size, its left edge at the pen position, and its vertical centre on the middle of the
/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap
/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at
/// the default scale, stays inside the font's ascent and descent so the leading doesn't change.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InlineArt {
/// Name of the document symbol ([`crate::Symbol::name`]).
pub symbol: String,
/// Art height as a multiple of the run's font size.
#[serde(default = "one")]
pub scale: f64,
/// Extra raise in points (positive = up).
#[serde(default)]
pub baseline_shift: f64,
/// The symbol art's visual bounds at its natural size, resolved from the document's symbols by
/// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the
/// layout then reserves a one-em square and nothing is drawn.
#[serde(skip)]
pub bounds: Option<Rect>,
}
fn one() -> f64 {
1.0
}
impl InlineArt {
pub fn new(symbol: impl Into<String>) -> Self {
Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None }
}
/// `scale`, made finite and positive (untrusted input).
pub fn safe_scale(&self) -> f64 {
if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 }
}
/// `baseline_shift`, made finite (untrusted input).
pub fn safe_shift(&self) -> f64 {
if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -684,6 +879,11 @@ fn reverse_from_end(path: &mut PathData) {
}
/// A text object. `runs` split into paragraphs at `\n`.
///
/// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every
/// paragraph's). Invariant (kept by [`TextObject::normalize_paras`]): `paras` is either empty —
/// every paragraph uses `para` — or holds one style per paragraph with `paras[0] == para` and at
/// least two different styles. Readers that only know `para` see the first paragraph's style.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
@@ -695,6 +895,9 @@ pub struct TextObject {
pub runs: Vec<TextRun>,
#[serde(default)]
pub para: ParaStyle,
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paras: Vec<ParaStyle>,
/// Area Type Options (area type only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub area: AreaOptions,
@@ -727,8 +930,9 @@ impl TextObject {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
runs: vec![TextRun::new(text, style)],
para: ParaStyle::default(),
paras: Vec::new(),
area: AreaOptions::default(),
path_effect: PathEffect::default(),
path_align: PathAlign::default(),
@@ -741,6 +945,103 @@ impl TextObject {
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
/// Number of paragraphs (`\n`-separated; empty text has one).
pub fn paragraph_count(&self) -> usize {
1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>()
}
/// Attributes of paragraph `i` (the last paragraph's past the end).
pub fn para_at(&self, i: usize) -> &ParaStyle {
self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para)
}
/// Every paragraph's attributes, one per paragraph.
pub fn paragraph_styles(&self) -> Vec<ParaStyle> {
(0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect()
}
/// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its
/// last entry, a long one is cut).
pub fn set_paragraph_styles(&mut self, styles: Vec<ParaStyle>) {
if let Some(first) = styles.first() {
self.para = first.clone();
}
self.paras = styles;
self.normalize_paras();
}
/// The same attributes for every paragraph.
pub fn set_all_paras(&mut self, style: ParaStyle) {
self.para = style;
self.paras.clear();
}
/// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the
/// last), `paras[0] == para`, and empty when every paragraph is alike.
pub fn normalize_paras(&mut self) {
if self.paras.is_empty() {
return;
}
let n = self.paragraph_count();
let last = self.paras.last().cloned().unwrap_or_default();
self.paras.resize(n, last);
if let Some(first) = self.paras.first_mut() {
first.clone_from(&self.para);
}
if self.paras.iter().all(|p| *p == self.para) {
self.paras.clear();
}
}
/// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret
/// touches its paragraph).
pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range<usize> {
let text = self.plain_text();
let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len()));
let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count());
index(a)..index(b) + 1
}
/// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph).
pub fn edit_paras(&mut self, range: Option<std::ops::Range<usize>>, mut f: impl FnMut(&mut ParaStyle)) {
match range {
None => {
f(&mut self.para);
for p in &mut self.paras {
f(p);
}
}
Some(r) => {
let mut v = self.paragraph_styles();
for p in v.iter_mut().take(r.end).skip(r.start) {
f(p);
}
self.set_paragraph_styles(v);
}
}
self.normalize_paras();
}
/// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames.
pub fn para_styles_mut(&mut self) -> impl Iterator<Item = &mut ParaStyle> {
std::iter::once(&mut self.para).chain(self.paras.iter_mut())
}
/// Every stored paragraph style (`para` and the per-paragraph ones).
pub fn para_styles(&self) -> impl Iterator<Item = &ParaStyle> {
std::iter::once(&self.para).chain(self.paras.iter())
}
/// Update the paragraph styles for replacing bytes `start..end` of the current plain text by
/// `insert` (call before changing the runs). The paragraph the range starts in keeps its
/// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that
/// style too (Return continues the paragraph it splits); later paragraphs keep theirs.
pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) {
if self.paras.is_empty() {
return;
}
let r = self.paragraphs_in(start, end);
let v = self.paragraph_styles();
let Some(keep) = v.get(r.start).cloned() else { return };
let added = insert.bytes().filter(|&b| b == b'\n').count();
let mut out: Vec<ParaStyle> = v.iter().take(r.start + 1).cloned().collect();
out.extend(std::iter::repeat_n(keep, added));
out.extend(v.iter().skip(r.end).cloned());
self.paras = out;
if let Some(first) = self.paras.first() {
self.para = first.clone();
}
}
pub fn first_style(&self) -> CharStyle {
self.runs.first().map(|r| r.style.clone()).unwrap_or_default()
}
@@ -868,6 +1169,56 @@ mod tests {
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
}
fn justified(j: Justify) -> ParaStyle {
ParaStyle { justify: j, ..ParaStyle::default() }
}
#[test]
fn paragraph_styles_split_merge_and_normalize() {
let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default());
assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored");
assert_eq!(t.paragraphs_in(0, 0), 0..1);
assert_eq!(t.paragraphs_in(4, 4), 1..2);
assert_eq!(t.paragraphs_in(2, 9), 0..3);
assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text");
t.edit_paras(Some(1..2), |p| p.justify = Justify::Center);
let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::<Vec<_>>();
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
// Return inside "two": the new paragraph continues its style.
t.splice_paras(5, 5, "\n");
t.runs[0].text.insert(5, '\n');
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]);
// Deleting the break between "one" and "t": the first paragraph's style wins.
t.splice_paras(3, 4, "");
t.runs[0].text.remove(3);
assert_eq!(t.plain_text(), "onet\nwo\nthree");
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
// An edit that skips the splice is repaired by normalizing (the last style continues).
t.runs[0].text.push_str("\nfour");
t.normalize_paras();
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]);
// `para` is paragraph 0's; all alike collapses to `para` alone.
t.edit_paras(Some(0..1), |p| p.justify = Justify::Right);
assert_eq!(t.para.justify, Justify::Right);
t.edit_paras(None, |p| p.justify = Justify::Right);
assert!(t.paras.is_empty() && t.para.justify == Justify::Right);
// A short list repeats its last entry, a long one is cut.
t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]);
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]);
t.set_paragraph_styles(vec![justified(Justify::Center); 9]);
assert!(t.paras.is_empty() && t.para.justify == Justify::Center);
// Old documents (no `paras`) load; new ones keep paragraph 0 in `para`.
t.edit_paras(Some(3..4), |p| p.space_before = 4.0);
let json = serde_json::to_value(&t).unwrap();
assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]);
let mut old = json.clone();
old.as_object_mut().unwrap().remove("paras");
let back: TextObject = serde_json::from_value(old).unwrap();
assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center);
let back: TextObject = serde_json::from_value(json).unwrap();
assert_eq!(back.para_at(3).space_before, 4.0);
}
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
fn area_type() -> TextObject {
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
+11 -6
View File
@@ -324,14 +324,19 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
Some(m)
}
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
/// document has none (export it as is).
/// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the
/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when
/// there is nothing to do (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
if !doc.layers.iter().any(|l| needs_bake(l)) {
return None;
let placed = doc.with_placed_art();
if !placed.layers.iter().any(|l| needs_bake(l)) {
return match placed {
std::borrow::Cow::Owned(d) => Some(d),
std::borrow::Cow::Borrowed(_) => None,
};
}
let mut d = doc.clone();
let layers = doc.layers.clone();
let mut d = placed.into_owned();
let layers = d.layers.clone();
d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
Some(d)
}
+1
View File
@@ -26,6 +26,7 @@ pub fn needs_outline(n: &Node) -> bool {
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
)
}
+3 -2
View File
@@ -803,9 +803,10 @@ impl Picked {
run_attrs(&mut r.style, &placed, ap);
}
if let Some((_, para)) = text.filter(|_| a.paragraph) {
t.para = para.clone();
// The source's first paragraph's attributes, on every paragraph.
t.set_all_paras(para.clone());
}
if text.is_some() && a.character {
if text.is_some() && (a.character || a.paragraph) {
super::typecmd::refresh_bounds(t);
}
return;
+2 -2
View File
@@ -18,10 +18,10 @@ use super::edit::selected_roots;
use super::*;
use crate::EngineError;
const SIZES_KEY: &str = "symbolSizes";
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
const CURRENT_SYMBOL: &str = "currentSymbol";
/// Half the instance box `Node::geometric_bounds` uses for symbol instances.
const HALF: f64 = 10.0;
const HALF: f64 = vectorcraft_doc::SYMBOL_HALF;
pub fn specs() -> Vec<CommandSpec> {
vec![
+44 -7
View File
@@ -14,7 +14,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10)}} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -250,7 +250,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let union = if picked.is_empty() { PathData::default() } else { po::merge_regions(&picked) };
let faces = po::FaceMerger::new(&map.shapes);
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
// The merged shape takes the look of the front-most object over the first region, or (for an
// area only lines enclose) of the front-most object and the current fill.
let covered = picked.iter().find_map(|r| r.top());
@@ -278,7 +279,17 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// its outline, shared edges from every shape along them).
let in_union = picked.iter().any(|r| r.sources.contains(&i));
let mut rest = if in_union {
po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference)
// What is left of it: its faces not merged or deleted, united. They share their
// edges with the merged faces exactly, where subtracting the merged shape left
// slivers of the outline along them (#569).
let kept: Vec<&po::Region> = map
.regions
.iter()
.enumerate()
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
.map(|(_, (r, ..))| r)
.collect();
if kept.is_empty() { PathData::default() } else { faces.merge(&kept) }
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
@@ -637,9 +648,21 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
for (k, val) in src {
o.insert(k.clone(), val.clone());
}
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?;
if !params.fills && !params.strokes {
return Err(bad("imageTrace.make", "Create needs fills, strokes or both"));
}
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels"));
}
Ok(params)
}
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
/// refused with advice instead (#525).
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let params = trace_params(p)?;
@@ -671,11 +694,13 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let res = tr::trace(&raster, &params);
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
// Document units per raster pixel (stroke widths).
let unit = xf.determinant().abs().sqrt();
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
let id = s.edit(label, |d, sel| {
@@ -698,7 +723,19 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
for tp in res.paths {
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])));
let paint = Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2]));
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
Some(width) => Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Round,
join: LineJoin::Round,
..StrokeLayer::new(paint, width * unit)
})],
..Default::default()
},
None => fill_only(paint),
};
children.push(Arc::new(n));
}
let gid = d.alloc_id();
+40 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
pub(crate) use resources::SwatchChoices;
use super::*;
@@ -220,6 +220,25 @@ mod tests {
s
}
/// The address a browser's Copy Image puts next to the picture (#597), and text that isn't one.
#[test]
fn an_address_alone_is_told_from_other_text() {
for a in [
"https://example.com/cat.png",
" http://x.org/a?b=1
",
"HTTPS://EXAMPLE.COM",
"file:///C:/art/cat.png",
"data:image/png;base64,iVBOR",
"blob:https://x.org/1",
] {
assert!(is_address(a), "{a}");
}
for t in ["Pasted words", "see https://example.com", "https://a.org https://b.org", "https://", "example.com/cat.png", ""] {
assert!(!is_address(t), "{t}");
}
}
#[test]
fn copy_exports_svg_that_round_trips_through_paste() {
let mut s = session();
@@ -240,6 +259,26 @@ mod tests {
assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6);
}
#[test]
fn pasted_type_gets_its_layout_bounds_cache() {
// Reported from Illustrator: pasted type kept no layout bounds, so its box (and alignment)
// fell back to the rough estimate until the file was saved and reopened.
let mut s = session();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="120"><text x="0" y="80" font-family="Source Sans 3" font-size="40">WWWWWWWW</text></svg>"#;
s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let mut checked = 0;
s.doc().unwrap().doc.walk(|n| {
let vectorcraft_doc::NodeKind::Text(t) = &n.kind else { return };
let cached = t.cached_bounds.expect("pasted type computed its bounds");
let real = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds;
assert!((cached.width() - real.width()).abs() < 1e-6, "{cached:?} {real:?}");
assert!(cached.width() > t.estimate_bounds().width() + 1.0, "{cached:?} is just the estimate");
checked += 1;
});
assert_eq!(checked, 1, "one text object pasted");
}
#[test]
fn paste_never_lands_in_an_object_that_reused_an_undone_layers_id() {
// Found by the model-based test: undo restores the id counter, so a compound path made
@@ -30,6 +30,15 @@ pub const EMF: &str = "image/emf";
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
/// (word processors offer a picture of copied text too).
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
/// address alone pastes as text, and nothing is fetched.
pub fn is_address(text: &str) -> bool {
const SCHEMES: [&str; 6] = ["http://", "https://", "file://", "ftp://", "data:", "blob:"];
let t = text.trim();
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
}
/// How much of a copied file [`file_flavour`] needs to tell what it is.
pub const FILE_HEAD: usize = 4096;
+7 -3
View File
@@ -122,11 +122,12 @@ fn node_refs(n: &Node, f: Visit) {
f(Ref::Name(Res::CharStyle, s));
}
}
if let Some(s) = &t.para.style_name {
for s in t.para_styles().filter_map(|pa| pa.style_name.as_ref()) {
f(Ref::Name(Res::ParaStyle, s));
}
}
NodeKind::Image(im) => f(Ref::Name(Res::Image, &im.key)),
NodeKind::PlacedDocument(pl) => f(Ref::Name(Res::Image, &pl.key)),
NodeKind::SymbolInstance { symbol, .. } => f(Ref::Name(Res::Symbol, symbol)),
_ => {}
}
@@ -439,11 +440,14 @@ impl Relink {
self.name(Res::CharStyle, s);
}
}
if let Some(s) = &mut t.para.style_name {
self.name(Res::ParaStyle, s);
for pa in t.para_styles_mut() {
if let Some(s) = &mut pa.style_name {
self.name(Res::ParaStyle, s);
}
}
}
NodeKind::Image(im) => self.name(Res::Image, &mut im.key),
NodeKind::PlacedDocument(pl) => self.name(Res::Image, &mut pl.key),
NodeKind::SymbolInstance { symbol, .. } => self.name(Res::Symbol, symbol),
_ => {}
}
+69 -7
View File
@@ -9,8 +9,16 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("shape.rectangle", "Rectangle", [], None, "{x, y, width, height, radius?: pt} → {id}", has_doc, rectangle),
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse),
cmd!(
"shape.rectangle",
"Rectangle",
[],
None,
"{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}",
has_doc,
rectangle
),
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse),
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
cmd!(
@@ -50,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText)} → {id}",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -163,6 +171,17 @@ fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
Ok(Rect::new(x, y, x + w, y + h).abs())
}
fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", cmd)? } else { 0.0 };
if !rotation.is_finite() {
return Err(bad(cmd, "rotation must be finite"));
}
if rotation == 0.0 {
return Ok(Affine::translate(r.origin().to_vec2()));
}
Ok(Affine::translate(r.center().to_vec2()) * Affine::rotate(rotation.to_radians()) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0)))
}
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangle")?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
@@ -171,7 +190,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
h: r.height(),
radii: [radius; 4],
kinds: Default::default(),
xf: Affine::translate(r.origin().to_vec2()),
xf: rect_transform(r, p, "shape.rectangle")?,
};
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
@@ -179,7 +198,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.ellipse")?;
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: Affine::translate(r.origin().to_vec2()) };
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: rect_transform(r, p, "shape.ellipse")? };
add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None)
}
@@ -410,14 +429,17 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
t.para = new_type_para();
new_type_alignment(s, p, "text.create", &mut t)?;
if let Some(a) = p.get("area") {
let w = f64_or(a, "width", 200.0);
let h = f64_or(a, "height", 100.0);
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
t.area.fit = new_area_fit(s, a, "text.create")?;
}
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
// Lays the text out (and sizes an Auto Size frame).
super::typecmd::refresh_bounds(&mut t);
}
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
@@ -425,8 +447,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
/// with Line-end Punctuation Half Width and Standard burasagari (as in Illustrator). Imported text
/// keeps its own.
/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own.
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
vectorcraft_doc::ParaStyle {
justify: vectorcraft_doc::Justify::Auto,
@@ -436,6 +457,47 @@ pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
}
}
/// The leading model and character alignment new type starts with (`t`'s paragraph and runs):
/// `leadingModel` and `charAlign` from `p`, else em box top to top and em box centre while the
/// interface is in Japanese (#432), else the Roman baseline for both. Noted in the journal, so a
/// replay sets the same whatever the interface.
pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut TextObject) -> Result<()> {
use vectorcraft_doc::{CharAlign, LeadingModel};
let japanese = s.japanese_interface();
let leading = match p.get("leadingModel").filter(|v| !v.is_null()) {
Some(v) => match v.as_str() {
Some("romanBaseline") => LeadingModel::RomanBaseline,
Some("emBoxTop") => LeadingModel::EmBoxTop,
_ => return Err(bad(cmd, "`leadingModel` must be \"romanBaseline\" or \"emBoxTop\"")),
},
None => {
let l = if japanese { LeadingModel::EmBoxTop } else { LeadingModel::RomanBaseline };
s.note_journal("leadingModel", json!(l));
l
}
};
let align = match super::textedit::char_align_param(p, cmd)? {
Some(a) => a,
None => {
let a = if japanese { CharAlign::EmBoxCenter } else { CharAlign::RomanBaseline };
s.note_journal("charAlign", json!(a));
a
}
};
t.para.leading_model = leading;
for r in &mut t.runs {
r.style.char_align = align;
}
Ok(())
}
/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes
/// them), else Auto Size when the "Auto Size New Area Type" preference is on.
pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraft_doc::AreaFit> {
let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None };
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
}
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
/// defaults, filled with the current fill unless that is None or white (black then).
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
+1
View File
@@ -418,6 +418,7 @@ fn not_liquified(n: &Node) -> Option<&'static str> {
NodeKind::Mesh(_) => "meshes",
NodeKind::Envelope { .. } => "envelopes",
NodeKind::Repeat(_) => "repeats",
NodeKind::PlacedDocument(_) => "placed documents",
NodeKind::Blend { .. } => "blends",
_ => return None,
})
+3 -1
View File
@@ -49,13 +49,14 @@ pub const CATEGORIES: [(&str, &str); 11] = [
];
/// The object counts of `document.info` and their labels.
const OBJECT_LABELS: [(&str, &str); 16] = [
const OBJECT_LABELS: [(&str, &str); 17] = [
("paths", "Paths"),
("compoundPaths", "Compound Paths"),
("groups", "Groups"),
("clipGroups", "Clipping Masks"),
("textObjects", "Text Objects"),
("images", "Images"),
("placedDocuments", "Placed Documents"),
("symbolInstances", "Symbol Instances"),
("gradients", "Gradient Objects"),
("patterns", "Pattern Objects"),
@@ -252,6 +253,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
NodeKind::Envelope { .. } => Some("envelopes"),
NodeKind::Mesh(_) => Some("meshes"),
NodeKind::Repeat(_) => Some("repeats"),
NodeKind::PlacedDocument(_) => Some("placedDocuments"),
};
if let Some(k) = k {
*counts.entry(k).or_default() += 1;
+3
View File
@@ -381,6 +381,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
}
}
sel.set(new_ids.iter().copied());
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
// right away (opening the file later would, but paste must not depend on a reopen).
super::typecmd::refresh_bounds_of(d, &new_ids);
Ok((new_ids, imported, artboard))
})?;
// The Artboard tool takes the pasted artboard.
+28 -4
View File
@@ -1,6 +1,7 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
//! (Fill) into plain art in one undo step. 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
//! (Fill) into plain art in one undo step. 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.
use std::sync::Arc;
@@ -36,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, envelopes → their distorted content), 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, 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
),
@@ -55,6 +56,21 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
/// shows, keeping its id and place; the resources the art uses join `d` (a name `d` uses for
/// something else gets a free variant).
fn expand_placed_under(d: &mut Document, roots: &[NodeId]) -> Result<()> {
let mut ids = vec![];
for n in roots.iter().filter_map(|r| d.node(*r)) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::PlacedDocument(_)) {
ids.push(c.id);
}
});
}
ids.into_iter().try_for_each(|id| super::place::document::expand(d, id, C))
}
/// Expand Gradient To.
#[derive(Clone, Copy, Debug, PartialEq)]
enum GradientTo {
@@ -88,6 +104,8 @@ struct Expandable {
envelope: bool,
/// Live blends (Object expands them into groups of their steps).
blends: bool,
/// Placed documents (Object expands them into the art they show).
placed: bool,
}
impl Expandable {
@@ -101,6 +119,7 @@ impl Expandable {
e.effects |= !c.appearance.effects.is_empty();
e.envelope |= matches!(c.kind, NodeKind::Envelope { .. });
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
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(_))),
@@ -112,7 +131,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends
self.text || self.live || self.effects || self.envelope || self.blends || self.placed
}
}
@@ -135,6 +154,11 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let from = s.doc()?.history.undo.len();
let rev0 = s.doc()?.revision;
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// Placed documents first: their art (type, strokes, gradients) expands below.
if object && e.placed {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
}
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
+22
View File
@@ -364,6 +364,22 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(
/// [`encode`], with every file the export writes.
pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
// Placed documents output their files' art (a native file keeps what it keeps: see
// `native::encode`).
if !matches!(format, "vectorcraft" | "template") {
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
if !warnings.is_empty() {
let mut enc = encode_all_of(&full, format, p)?;
enc.warnings.extend(warnings);
return Ok(enc);
}
return encode_all_of(&full, format, p);
}
encode_all_of(doc, format, p)
}
/// [`encode_all`] of `doc` as it is.
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
let mut enc = encode_files(doc, f, p)?;
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
@@ -401,6 +417,12 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
"txt" => super::text::encode(doc, p)?,
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
"dxf" => return super::dxf::encode(doc, p, use_artboards),
// As Save As writes it (a PDF of every artboard carrying the native document).
"ai" => {
let mut q = p.clone();
super::save::ai_params(&mut q);
return super::save::encode_ai(C, f, doc, &q);
}
// EPS reads its own `useArtboards` (the art's bounds unless asked).
"eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
+20
View File
@@ -87,6 +87,26 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
Some(out)
}
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
/// bounds clip them), as DXF's crop leaves them. A clipping layer keeps every object, its clipping
/// path among them. An artboard's file then holds its own art, not a whole document of artboards.
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
fn kept(n: &Arc<Node>, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
let mut layer = Node::clone(n);
let children = layer.children_mut()?;
*children = children.iter().filter_map(|c| kept(c, rect)).collect();
return Some(Arc::new(layer));
}
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
n.visual_bounds().is_none_or(over).then(|| n.clone())
}
let mut d = doc.clone();
d.layers = doc.layers.iter().filter_map(|l| kept(l, rect)).collect();
d
}
/// `p` without its artboard choice, also inside its SVG options (for documents made of one
/// synthetic artboard, and for callers that pick the artboard themselves).
pub(super) fn without_artboards(p: &Value) -> Value {
+2 -2
View File
@@ -465,8 +465,8 @@ pub const FORMATS: &[Format] = &[
extensions: &["ai"],
mime: "application/pdf",
read: true,
// Saved (Save As), not exported.
write: false,
// Save As writes it; exports write the same file.
write: true,
raster: false,
options: native::AI_OPTIONS,
},
+7
View File
@@ -127,6 +127,13 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
let (mut so, extras) = save_options(cmd, f, doc, p)?;
// Include Linked Files keeps placed documents' files; older versions get their art.
let full = if so.include_linked || so.version < vectorcraft_format::VERSION {
crate::cmd::place::document::full_documents(doc).0
} else {
std::borrow::Cow::Borrowed(doc)
};
let doc = &*full;
if extras.preview {
so.preview = preview_png(doc)?;
}
+2 -2
View File
@@ -86,7 +86,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
name: "createLayers",
ty: "boolean",
default: "false",
description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)",
description: "layers and sublayers as PDF layers (optional content groups with their visibility, print state and lock, sublayers listed under their layers; PDF 1.5 or later)",
},
FormatOption {
name: "flattenerPreset",
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet c Line truncated
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options ac Line truncated
has_doc,
export_pdf
),
+56 -30
View File
@@ -216,15 +216,20 @@ fn is_svg(f: &Format) -> bool {
matches!(f.id, "svg" | "svgz")
}
/// The document as written: native files carry the view to reopen at.
/// The document as written: native files carry the view to reopen at and the Layers panel's open
/// rows.
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
if is_native(f) && st.doc.last_view != st.view {
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
Arc::new(d)
} else {
st.doc.clone()
if !is_native(f) {
return st.doc.clone();
}
let open = st.layers_open.saved(&st.doc);
if st.doc.last_view == st.view && st.doc.layers_open == open {
return st.doc.clone();
}
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
d.layers_open = open;
Arc::new(d)
}
/// What a format loses against a native file (reported whenever a save writes it).
@@ -274,6 +279,7 @@ fn artboard_doc(doc: &Document, i: usize) -> Document {
d.artboards = vec![a.clone()];
// It opens fitted to its artboard, not at the master file's view.
d.last_view = None;
d.layers_open = None;
for layer in &mut d.layers {
keep_on(layer, r);
}
@@ -305,6 +311,40 @@ fn blank_pages(doc: &Document) -> Document {
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
/// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard
/// with the PDF options, always carrying the native document, its layers and sublayers PDF layers
/// (hidden ones off) unless asked otherwise; Use Compression compresses the PDF's content.
pub(super) fn ai_params(params: &mut Value) {
let Some(o) = params.as_object_mut() else { return };
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
// Apps that read the PDF part (not the native document) then find the layers, the hidden ones
// with their art (#372); a PDF standard decides for itself.
if o.get("standard").is_none_or(Value::is_null) {
o.entry("createLayers").or_insert(json!(true));
}
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
let compression = o.entry("compression").or_insert_with(|| json!({}));
if let Some(m) = compression.as_object_mut() {
m.insert("compressText".into(), json!(c));
}
}
}
/// Encode `doc` as a PDF-compatible `.ai` file (`params` as [`ai_params`] makes them) for command
/// `cmd`: its pages (blank with `pdfCompatible: false`, which it then notes), carrying the native
/// document. File › Save As writes it, and so do the exports (`document.export`, the CLI).
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, params: &Value) -> Result<Encoded> {
let compatible = bool_or(params, "pdfCompatible", true);
let pages = doc.without_edit_modes();
let pages = if compatible { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
let native = || super::native::ai_native(cmd, f, doc, params);
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, params, native)?;
if !compatible {
warnings.insert(0, NOT_PDF_COMPATIBLE.to_string());
}
Ok(Encoded { warnings, ..Encoded::one(bytes) })
}
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
/// document's own). Bad options fail here, before anything is encoded.
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
@@ -339,16 +379,8 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
}
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
// A PDF of every artboard with the PDF options, always carrying the native document.
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
// Use Compression compresses the PDF's content.
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
let compression = o.entry("compression").or_insert_with(|| json!({}));
if let Some(m) = compression.as_object_mut() {
m.insert("compressText".into(), json!(c));
}
}
if plan.format.id == "ai" {
ai_params(&mut params);
}
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
}
@@ -372,25 +404,14 @@ impl SaveJob {
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
}
let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true);
let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string()));
enc.warnings.splice(0..0, notes);
enc.warnings.splice(0..0, fidelity_warning(self.plan.format));
Ok(enc)
}
/// Encode one file of `doc` in the job's format.
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let enc = if f.id == "ai" {
// The pages (blank without PDF content), carrying the native document.
let pages = doc.without_edit_modes();
let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
let native = || super::native::ai_native(cmd, f, doc, &self.params);
let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?;
Encoded { warnings, ..Encoded::one(bytes) }
} else {
encode_all(doc, f.id, &self.params)?
};
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
if enc.files.len() != 1 {
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
}
@@ -450,12 +471,17 @@ impl SaveJob {
/// its own also ends its Data Recovery copy.
pub fn complete(self, s: &mut Session, uid: u64) {
let saved = self.plan.retargets() && self.plan.path.is_some();
// The other open documents that place this one show the new version.
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
if let Some(st) = s.document_mut(uid) {
self.finish(st);
}
if saved {
crate::cmd::recovery::forget(s, uid);
}
if let Some(path) = written {
crate::cmd::links::refresh_placed(s, uid, &path);
}
}
}
+1 -1
View File
@@ -161,7 +161,7 @@ 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" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
+14 -9
View File
@@ -19,7 +19,7 @@ pub const OPTIONS: &[FormatOption] = &[
name: "useArtboards",
ty: "boolean",
default: "true",
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each)",
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each; a named artboard's file holds only the art over it)",
},
FormatOption {
name: "styling",
@@ -70,7 +70,7 @@ pub const OPTIONS: &[FormatOption] = &[
name: "hiddenLayers",
ty: "boolean",
default: "false",
description: "keep hidden layers, not displayed (display:none); document.save sets it unless given",
description: "keep hidden layers and objects, not displayed (display=\"none\"); document.save sets it unless given",
},
FormatOption {
name: "encoding",
@@ -125,8 +125,10 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
Ok(m)
}
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers,
/// and whether `p` named those artboards (else the first stands for the whole document, as Save
/// writes it).
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>, bool), String> {
let mut m = options_map(p)?;
// Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines).
if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance {
@@ -138,21 +140,24 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>)
opts.check().map_err(|e| format!("SVG options: {e}"))?;
let n = doc.artboards.len();
if n == 0 || boards.use_artboards == Some(false) {
return Ok((opts, vec![None]));
return Ok((opts, vec![None], false));
}
let picked = boards.pick.resolve(n)?.unwrap_or_else(|| vec![0]);
Ok((opts, picked.into_iter().map(Some).collect()))
let named = boards.pick.resolve(n)?;
let chosen = named.is_some();
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
}
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
/// link to and the writer's warnings.
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
let (opts, boards) = plan(doc, p)?;
let (opts, boards, chosen) = plan(doc, p)?;
// Preserve editing embeds the native document (once, shared by every file).
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
let mut enc = Encoded::default();
for artboard in boards {
let mut out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
// A chosen artboard's file holds the art over it (#550).
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
let bytes = out.take_bytes();
for l in out.linked {
if !enc.linked.iter().any(|e| e.name == l.name) {
+2
View File
@@ -173,6 +173,7 @@ fn export_extensions_name_every_writable_format() {
"svg",
"svgz",
"pdf",
"ai",
"png",
"jpg",
"gif",
@@ -206,6 +207,7 @@ fn formats_query_lists_readers_writers_and_options() {
"svg",
"svgz",
"pdf",
"ai",
"png",
"jpg",
"gif",
@@ -118,6 +118,27 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
assert_eq!(s.doc().unwrap().path, None);
}
/// #526: the exports (`document.export`, the CLI's `--export` and `convert`) write `.ai` as Save
/// As does, leaving the document's own path alone.
#[test]
fn exports_write_ai_as_save_as_does() {
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
let path = tmp("exported.ai");
let r = s.execute("document.export", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ai", "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
assert_eq!(s.doc().unwrap().path, None, "an export doesn't give the document a path");
let r = s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(true)), "{r}");
assert_eq!(comparable(&s.doc().unwrap().doc), before);
// Its options as Save As takes them; no path gives the bytes.
let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("without PDF content"))), "{r}");
assert!(b64(&r).starts_with(b"%PDF"));
}
#[test]
fn ait_templates_open_their_document_untitled() {
let mut s = rich();
@@ -59,3 +59,35 @@ fn export_pdf_overprint_is_preserved_or_discarded() {
assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1);
assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0);
}
/// A `.ai` file's PDF part, which apps that don't read the native document open, has the layers
/// and sublayers as PDF layers, the hidden ones with their art (#372).
#[test]
fn ai_files_write_their_layers_and_sublayers_as_pdf_layers() {
let mut s = session();
let layer1 = s.execute("document.inspect", &json!({})).unwrap()["layers"][1]["id"].as_u64().unwrap();
let sub = s.execute("layer.newSublayer", &json!({"parent": layer1, "name": "Sketch"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
s.execute("shape.ellipse", &json!({"x": 10, "y": 40, "width": 20, "height": 20})).unwrap();
s.execute("layer.setProps", &json!({"id": sub, "visible": false})).unwrap();
// Name, shown and what it holds (sublayers by name), top-level layers in paint order.
fn rows(layers: &[std::sync::Arc<vectorcraft_doc::Node>]) -> Vec<(String, bool, Vec<String>)> {
layers
.iter()
.map(|l| {
let held =
l.children().into_iter().flatten().map(|c| c.name.clone().filter(|_| c.is_layer()).unwrap_or_else(|| "art".into())).collect();
(l.name.clone().unwrap_or_default(), l.visible, held)
})
.collect()
}
let v = s.execute("document.export", &json!({"format": "ai"})).unwrap();
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
let art = || "art".to_string();
assert_eq!(rows(&back.layers), [("Layer 1".to_string(), true, vec![art(), "Sketch".into()]), ("Notes".to_string(), false, vec![art()])]);
let sketch = back.layers.first().and_then(|l| l.children()?.last().cloned()).unwrap();
assert_eq!(rows(&[sketch]), [("Sketch".to_string(), false, vec![art()])]);
// Asked not to, it writes none.
let v = s.execute("document.export", &json!({"format": "ai", "createLayers": false})).unwrap();
assert_eq!(vectorcraft_pdf::import(&b64(&v)).unwrap().layers.len(), 1);
}
@@ -49,3 +49,28 @@ fn profile_and_embedded_fonts_reach_the_writer() {
assert!(s.execute("document.serialize", &json!({"format": "svg", "svg": {"profile": "svg2"}})).is_err(), "unknown profiles are refused");
assert!(s.execute("document.serialize", &json!({"format": "svg", "encoding": "ebcdic"})).is_err(), "unknown encodings are refused");
}
/// #550: an SVG of chosen artboards holds the art over each, not every artboard's art; with no
/// artboard named (as Save writes it) the whole document's art is there.
#[test]
fn a_chosen_artboard_s_svg_holds_only_its_own_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap();
let x = |s: &Session, b: usize| s.doc().unwrap().doc.artboards[b].rect.x0;
let (first, third) = (x(&s, 0), x(&s, 2));
s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
// A rectangle has straight sides only; the ellipse is curves.
let shapes = |svg: &str| (svg.matches("<path").count() + svg.matches("<rect").count(), svg.contains(" C") || svg.contains("<ellipse"));
let text = |v: &serde_json::Value| v["text"].as_str().unwrap().to_string();
let third_only = text(&s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "3"})).unwrap());
assert_eq!(shapes(&third_only), (1, true), "{third_only}");
let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap();
let files: Vec<String> = all["files"].as_array().unwrap().iter().map(text).collect();
assert_eq!(files.iter().map(|f| shapes(f)).collect::<Vec<_>>(), [(1, false), (0, false), (1, true)]);
// Export for Screens names one artboard at a time the same way.
let screen = text(&s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap());
assert_eq!(shapes(&screen), (1, false));
let whole = text(&s.execute("document.serialize", &json!({"format": "svg"})).unwrap());
assert_eq!(shapes(&whole).0, 2, "nothing named: the whole document's art");
}
+2
View File
@@ -1185,6 +1185,8 @@ impl Scratch {
let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n));
art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g))
}
// A placed document stays one: its art's resources aren't the document's.
NodeKind::PlacedDocument(_) => None,
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
let hook: &dyn Fn(&Node) -> Option<Node> = &effects::outline_text;
Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook))))
+109
View File
@@ -0,0 +1,109 @@
//! Inline graphics in text: document symbols placed in a run of text, flowing like a glyph
//! (beyond Illustrator; InDesign's inline anchored objects). See [`vectorcraft_doc::InlineArt`].
use serde_json::{Value, json};
use vectorcraft_doc::text::INLINE_MAX_SCALE;
use vectorcraft_doc::{Document, InlineArt, NodeId, NodeKind, TextRun};
use vectorcraft_text::edit;
use super::typecmd::refresh_bounds;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"text.insertInline",
"Insert Inline Symbol",
["Type"],
None,
"{id?: text id (default: the text the Type tool edits), at?: byte offset (default: the Type tool's selection, which it replaces; clamped to the text), symbol?: symbol name (default: the Symbols panel's current symbol), scale?: art height ÷ font size = 1 (0 < scale ≤ 100), shift?: pt raise = 0} place a document symbol inline in the text as one character (U+FFFC) that flows like a glyph: its art is scaled to `scale` × the font size tall, its vertical centre on the middle of the cap height raised by `shift`. Typing, Backspace and Delete over it remove it whole → {id, caret}",
has_doc,
insert_inline
)]
}
/// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols,
/// resolve the inline art of every text again (only texts whose art changed are touched).
pub(crate) fn refresh(before: &Document, d: &mut Document) {
if before.symbols.is_empty() && d.symbols.is_empty() {
return;
}
for id in d.resolve_inline_art() {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
}
}
}
fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.insertInline";
let tool = s.tool_options();
let editing = tool.get("editing").and_then(Value::as_u64).map(NodeId);
let id = match p.get("id") {
None | Some(Value::Null) => editing.ok_or_else(|| bad(C, "give `id` (no text is being edited)"))?,
Some(_) => id_param(p, "id").ok_or_else(|| bad(C, "`id` must be a node id"))?,
};
let doc = &s.doc()?.doc;
let NodeKind::Text(t) = &doc.node(id).ok_or(EngineError::NoNode(id))?.kind else {
return Err(bad(C, format!("node {} is not text", id.0)));
};
let len = edit::runs_len(&t.runs);
let from_tool = editing == Some(id);
let (a, b) = match p.get("at") {
None | Some(Value::Null) if from_tool => {
let g = |k: &str| tool.get(k).and_then(Value::as_u64).map_or(len, |v| (v as usize).min(len));
let (x, y) = (g("start"), g("end"));
(x.min(y), x.max(y))
}
None | Some(Value::Null) => (len, len),
Some(v) => {
let at = v.as_u64().ok_or_else(|| bad(C, "`at` must be a byte offset"))?.min(len as u64) as usize;
(at, at)
}
};
let symbol = match str_param(p, "symbol") {
Some(n) if doc.symbols.iter().any(|s| s.name == n) => n.to_string(),
Some(n) => return Err(bad(C, format!("no symbol named `{n}`"))),
None if p.get("symbol").is_some_and(|v| !v.is_null()) => return Err(bad(C, "`symbol` must be a symbol name")),
None => current_symbol(doc).ok_or_else(|| EngineError::Other("the document has no symbols".into()))?,
};
let scale = match p.get("scale") {
None | Some(Value::Null) => 1.0,
Some(v) => v
.as_f64()
.filter(|k| k.is_finite() && *k > 0.0 && *k <= INLINE_MAX_SCALE)
.ok_or_else(|| bad(C, "`scale` must be a number in (0, 100]"))?,
};
let shift = match p.get("shift") {
None | Some(Value::Null) => 0.0,
Some(v) => v.as_f64().filter(|k| k.is_finite() && k.abs() <= 1e5).ok_or_else(|| bad(C, "`shift` must be a number of points"))?,
};
// A typing session ends first: the insertion is a step of its own.
let typing = tool.get("typing").and_then(Value::as_bool) == Some(true);
if typing && from_tool {
s.set_tool_option("commitTyping", &Value::Bool(true));
s.commit_interaction()?;
}
let caret = s.edit("Insert Inline Symbol", |d, _| {
let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) };
art.bounds = d.symbol_art_bounds(&symbol);
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let style = edit::insertion_style(&t.runs, a, b);
let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]);
refresh_bounds(t);
Ok(caret)
})?;
if from_tool {
s.set_tool_option("select", &json!({"start": caret, "end": caret}));
}
Ok(json!({"id": id.0, "caret": caret}))
}
/// The Symbols panel's current symbol, else the first one.
fn current_symbol(d: &Document) -> Option<String> {
d.unknown
.get("currentSymbol")
.and_then(Value::as_str)
.filter(|n| d.symbols.iter().any(|s| s.name == *n))
.map(str::to_string)
.or_else(|| d.symbols.first().map(|s| s.name.clone()))
}
+364 -118
View File
@@ -16,13 +16,17 @@
//! selects one (`links.goTo`), Embed keeps a linked file's pixels in the document
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to
//! (`links.unembed`).
//!
//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their
//! files the same way: checking, updating, relinking and finding them on open work alike, and they
//! are listed. Embed (Break Link) turns one into an editable copy of its art.
use std::collections::BTreeMap;
use std::path::{Component, Path};
use serde_json::{Value, json};
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacementOptions};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
@@ -35,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Links",
[],
None,
"{ids?: [image ids] (default: every linked image)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
has_doc,
check
),
@@ -44,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Update Links",
[],
None,
"{ids?: [image ids] (default: the linked images whose file was modified or is showing its preview)} read their linked files again; each image keeps its bounds. One undo step → {updated: [ids], missing: [ids]}",
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. One undo step → {updated: [ids], missing: [ids]}",
has_doc,
update
),
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Relink",
[],
None,
"{ids?: [image ids] (default: the selected images; with folder and nothing selected, every missing link), path | folder} link images to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}",
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}",
has_doc,
relink
),
@@ -62,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Links",
[],
None,
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview)}], missing, modified, embedded}",
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
has_doc,
list
),
@@ -71,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Go To Link",
[],
None,
"{id?} select image `id` (default: the first selected image); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}",
"{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}",
has_doc,
go_to
),
@@ -80,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Embed Image",
[],
None,
"{ids?: [image ids] (default: the selected linked images)} keep the linked files' pixels in the document and drop the links (an image whose file can't be found and that shows the saved preview stays linked: relink it first). One undo step → {embedded: [ids], missing: [ids]}",
"{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. One undo step → {embedded: [ids], missing: [ids]}",
has_doc,
embed
),
@@ -98,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link Info",
[],
None,
"{id?} an image's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch)",
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
has_doc,
info
),
@@ -107,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Placement Options",
[],
None,
"{ids?: [image ids] (default: the selected images), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}",
"{ids?: [image or placed document ids] (default: the selected ones), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}",
has_doc,
placement_options
),
@@ -134,22 +138,34 @@ impl Status {
}
}
/// The image objects that show one linked file as one content (same path, same image).
/// The image objects that show one linked file as one content (same path, same image), or the
/// placed documents that show one artboard of it.
struct Group {
link: LinkInfo,
key: String,
/// The objects in the layers (images in symbols and patterns share the content, not an id).
ids: Vec<NodeId>,
/// Placed documents: whether the art's bounds (not the artboard) are shown.
document: Option<bool>,
}
/// The linked images of `d` by file and content: those in the layers (only `ids` when given), then,
/// without `ids`, the ones only symbols and patterns show.
/// The linked object `n` as (link, content key, placed document's bounding).
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
match &n.kind {
NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)),
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))),
_ => None,
}
}
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
/// given), then, without `ids`, the ones only symbols and patterns show.
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
let mut out: Vec<Group> = vec![];
let mut index: BTreeMap<(String, String), usize> = BTreeMap::new();
let mut add = |link: &LinkInfo, key: &str, id: Option<NodeId>| {
let i = *index.entry((link.path.clone(), key.to_string())).or_insert_with(|| {
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![] });
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
let mut add = |link: &LinkInfo, key: &str, document: Option<bool>, id: Option<NodeId>| {
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
out.len() - 1
});
if let (Some(id), Some(g)) = (id, out.get_mut(i)) {
@@ -157,19 +173,19 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
}
};
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind
&& let Some(l) = &im.link
if let Some((l, key, document)) = linked(n)
&& ids.is_none_or(|ids| ids.contains(&n.id))
{
add(l, &im.key, Some(n.id));
add(l, key, document, Some(n.id));
}
});
if ids.is_none() {
d.visit_images(|_, im| {
if let Some(l) = &im.link {
add(l, &im.key, None);
add(l, &im.key, None, None);
}
});
d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None));
}
out
}
@@ -238,7 +254,7 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
let same = match &l.hash {
Some(h) => *h == hash_bytes(&bytes),
// Linked before links kept a hash: the image the file decodes to.
None => fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key),
None => g.document.is_none() && fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key),
};
f.bytes = Some(bytes);
(if same { Status::Ok } else { Status::Modified }, Some(f))
@@ -254,6 +270,15 @@ fn previewing(d: &Document, key: &str) -> bool {
d.images.get(key).is_none_or(ImageBlob::is_proxy)
}
/// Does group `g` of `d` show something other than its file: an image its preview, a placed
/// document nothing (its file's bytes are gone)?
fn stale(d: &Document, g: &Group) -> bool {
match g.document {
None => previewing(d, &g.key),
Some(_) => !d.images.contains_key(&g.key),
}
}
/// A linked file of a document (File → Package): its path, name and bytes.
pub(crate) struct LinkedFile {
pub path: String,
@@ -301,6 +326,51 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?))
}
/// A linked file read for the objects that show it.
enum Read {
Image(FileRead),
/// A VectorCraft document read for placed documents: the link to it and what they show.
Document(LinkInfo, super::place::document::Source),
}
/// The file at `f` read for group `g`: its image, or the artboard `g` shows.
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) };
let bytes = f.read()?;
let page = g.link.page.unwrap_or(1);
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?;
Ok(Read::Document(f.link(&bytes, &g.link), src))
}
/// Objects `ids` show what was read (see [`install`], [`install_document`]).
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, keep_bounds: bool) -> Result<()> {
match read {
Read::Image(r) => install(d, ids, r, keep_bounds),
Read::Document(link, src) => install_document(d, ids, &link, &src, keep_bounds),
}
}
/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its
/// Placement Options say (`keep_bounds`: in its bounds, whatever they say).
fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, keep_bounds: bool) -> Result<()> {
let mut clips = vec![];
let (w, h) = (src.frame.width(), src.frame.height());
for id in ids {
let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue };
let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..p.placement } } else { p.placement };
let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o);
if let Some(n) = d.node_mut(*id)
&& let NodeKind::PlacedDocument(p) = &mut n.kind
{
let p: &mut PlacedDocument = p;
(p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone());
}
clips.extend(clip.map(|c| (*id, c)));
}
super::place::document::store(d, src);
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
}
/// Points per pixel of the file image `key` of `d` shows, at the resolution it declares (72 ppi
/// when it declares none, or when only its preview is loaded).
fn file_pt(d: &Document, key: &str) -> (f64, f64) {
@@ -318,9 +388,10 @@ type Clip = (Affine, (f64, f64));
/// The transform an image placed with `xf` showing `old` pixels (`old_pt`: points per pixel at
/// 100%) gives a new file of `new` pixels (`new_pt`), as Placement Options `o` say; and the clip
/// when `o.clip` and the new art overflows the old bounds.
fn placed_xf(xf: Affine, old: (u32, u32), old_pt: (f64, f64), new: (u32, u32), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option<Clip>) {
let (ow, oh, nw, nh) = (old.0 as f64, old.1 as f64, new.0.max(1) as f64, new.1.max(1) as f64);
/// when `o.clip` and the new art overflows the old bounds. (A placed document's "pixels" are points,
/// one point each.)
fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option<Clip>) {
let (ow, oh, nw, nh) = (old.0, old.1, new.0.max(1e-9), new.1.max(1e-9));
let [a, b, c, d, ..] = xf.as_coeffs();
// The bounds' frame: the transform without its scale (rotation, reflection and position).
let (su, sv) = (a.hypot(b), c.hypot(d));
@@ -388,7 +459,8 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds:
for id in ids {
let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue };
let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..im.placement } } else { im.placement };
let (xf, clip) = placed_xf(im.xf, (im.width, im.height), file_pt(d, &im.key), (img.width, img.height), new_pt, o);
let size = |w: u32, h: u32| (w as f64, h as f64);
let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o);
if let Some(n) = d.node_mut(*id)
&& let NodeKind::Image(im) = &mut n.kind
{
@@ -436,11 +508,34 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
let row = rows(std::iter::once(&g));
match probe(&g, folder(doc_path)) {
(Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link).and_then(|read| install(d, &g.ids, read, false)) {
Ok(()) => out.updated.extend(row),
Err(_) => out.missing.extend(row),
},
(Status::Modified, Some(mut f)) if update => {
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, false)) {
Ok(()) => out.updated.extend(row),
Err(_) => out.missing.extend(row),
}
}
(Status::Modified, _) => out.modified.extend(row),
// A placed document keeps its file's bytes: only lost ones are read again.
(Status::Ok, Some(mut f)) if g.document.is_some() => {
let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None };
match read {
Some(Ok(read)) => {
if install_read(d, &g.ids, read, true).is_err() {
out.missing.extend(row);
}
}
Some(Err(_)) => out.missing.extend(row),
None => {
let link = match f.bytes.take() {
Some(b) => f.link(&b, &g.link),
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
};
if link != g.link {
set_links(d, &g.ids, &link);
}
}
}
}
(Status::Ok, Some(mut f)) => {
let preview = previewing(d, &g.key);
// Read when the images show the preview, or when the file's details changed.
@@ -471,17 +566,58 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
out
}
/// Image objects `ids` link to `link`.
/// The link of linked object `n` (a linked image or a placed document) to change.
fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> {
match &mut n.kind {
NodeKind::Image(im) => im.link.as_mut(),
NodeKind::PlacedDocument(p) => Some(&mut p.link),
_ => None,
}
}
/// Linked image objects (or placed documents) `ids` link to `link`.
fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) {
for id in ids {
if let Some(n) = d.node_mut(*id)
&& let NodeKind::Image(im) = &mut n.kind
{
im.link = Some(link.clone());
if let Some(l) = d.node_mut(*id).and_then(link_mut) {
*l = link.clone();
}
}
}
/// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open
/// documents: read again from the new file, each as one Update Links step of its document.
pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) {
let path = absolute_path(path);
let targets: Vec<(usize, Vec<u64>)> = s
.documents()
.iter()
.enumerate()
.filter(|(_, st)| st.uid != saved)
.filter_map(|(i, st)| {
let mut ids = vec![];
st.doc.walk(|n| {
if let NodeKind::PlacedDocument(p) = &n.kind
&& p.link.path == path
{
ids.push(n.id.0);
}
});
(!ids.is_empty()).then_some((i, ids))
})
.collect();
if targets.is_empty() {
return;
}
// Each document is updated as the active one, without switching tools.
let back = s.active;
for (i, ids) in targets {
s.active = Some(i);
// A file that can't be read leaves what its objects show: links.check reports it.
let _ = update(s, &json!({ "ids": ids }));
}
s.active = back;
}
// ---------- relative paths ----------
/// `target` relative to folder `base` with `/` separators; `None` when they share no root (another
@@ -509,9 +645,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
let dir = Path::new(&dest).parent()?;
let mut changes = vec![];
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind
&& let Some(l) = &im.link
{
if let Some((l, _, _)) = linked(n) {
let r = relative_path(Path::new(&l.path), dir);
if r != l.relative {
changes.push((n.id, r));
@@ -523,10 +657,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
}
let mut d = d.clone();
for (id, r) in changes {
if let Some(n) = d.node_mut(id)
&& let NodeKind::Image(im) = &mut n.kind
&& let Some(l) = &mut im.link
{
if let Some(l) = d.node_mut(id).and_then(link_mut) {
l.relative = r;
}
}
@@ -548,7 +679,7 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = found.filter(|f| f.path != g.link.path) {
row["found"] = json!(f.path);
}
if previewing(&st.doc, &g.key) {
if stale(&st.doc, g) {
row["preview"] = json!(true);
}
links.push(row);
@@ -564,9 +695,9 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
match probe(&g, folder(st.path.as_deref())) {
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
// Unchanged and showing the file: nothing to read unless asked for by id.
(Status::Ok, Some(_)) if ids.is_none() && !previewing(&st.doc, &g.key) => {}
// Unreadable as an image counts as missing.
(_, Some(mut f)) => match read_image(&mut f, &g.link) {
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
// Unreadable as an image (or a document) counts as missing.
(_, Some(mut f)) => match read_for(&mut f, &g, "links.update") {
Ok(read) => reads.push((g.ids, read)),
Err(_) => missing.extend(ids_json(&g.ids)),
},
@@ -574,7 +705,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
}
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
if !reads.is_empty() {
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?;
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?;
}
Ok(json!({ "updated": updated, "missing": missing }))
}
@@ -583,7 +714,14 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.relink";
let st = s.doc()?;
let folder_param = str_param(p, "folder");
let image = |id: &NodeId| st.doc.node(*id).and_then(|n| if let NodeKind::Image(im) = &n.kind { Some(im) } else { None });
// An image or a placed document, with its link.
let image = |id: &NodeId| {
st.doc.node(*id).and_then(|n| match &n.kind {
NodeKind::Image(im) => Some(im.link.as_ref()),
NodeKind::PlacedDocument(p) => Some(Some(&p.link)),
_ => None,
})
};
let ids: Vec<NodeId> = match ids_param(p, "ids") {
Some(ids) => ids,
None => {
@@ -599,7 +737,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
};
if let Some(id) = ids.iter().find(|id| image(id).is_none()) {
return Err(bad(C, format!("object {} is not an image", id.0)));
return Err(bad(C, format!("object {} is not an image or a linked file", id.0)));
}
if ids.is_empty() {
return Err(bad(C, "select the images to relink, or pass ids"));
@@ -611,7 +749,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
(Some(path), _) => files.push((ids, absolute_path(path))),
(None, Some(dir)) => {
for id in ids {
let Some(link) = image(&id).and_then(|im| im.link.as_ref()) else { continue };
let Some(link) = image(&id).flatten() else { continue };
let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy());
match files.iter_mut().find(|(_, p)| *p == path) {
Some((ids, _)) => ids.push(id),
@@ -625,12 +763,34 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let mut reads = vec![];
for (ids, path) in files {
let bytes = read_file(&path)?;
let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?;
reads.push((ids, (link_info(&path, &bytes), img)));
let link = link_info(&path, &bytes);
// Images show the file; placed documents each show their artboard of it.
let mut images = vec![];
let mut documents: Vec<((u32, bool), Vec<NodeId>)> = vec![];
for id in ids {
match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::PlacedDocument(pl)) => {
let k = (pl.page(), pl.bounding);
match documents.iter_mut().find(|(e, _)| *e == k) {
Some((_, ids)) => ids.push(id),
None => documents.push((k, vec![id])),
}
}
_ => images.push(id),
}
}
if !images.is_empty() {
let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?;
reads.push((images, Read::Image((link.clone(), img))));
}
for ((page, bounding), ids) in documents {
let src = super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?;
reads.push((ids, Read::Document(LinkInfo { page: Some(page), ..link.clone() }, src)));
}
}
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
if !reads.is_empty() {
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?;
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?;
}
not_found.sort();
not_found.dedup();
@@ -652,18 +812,6 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) ->
st.selection.objects.iter().copied().filter(|id| image_of(&st.doc, *id).is_some_and(&keep)).collect()
}
/// `ids` (else the selected images `keep` accepts), checked to be images; an error when none.
fn images_param(st: &crate::DocState, p: &Value, cmd: &str, keep: impl Fn(&ImageObject) -> bool) -> Result<Vec<NodeId>> {
let ids = ids_param(p, "ids").unwrap_or_else(|| selected_images(st, keep));
if let Some(id) = ids.iter().find(|id| image_of(&st.doc, **id).is_none()) {
return Err(bad(cmd, format!("object {} is not an image", id.0)));
}
if ids.is_empty() {
return Err(bad(cmd, "select the images, or pass ids"));
}
Ok(ids)
}
/// `id`, else the first selected image.
fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
@@ -674,6 +822,35 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
}
}
/// The Placement Options of image or placed document `n`.
fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> {
match &mut n.kind {
NodeKind::Image(im) => Some(&mut im.placement),
NodeKind::PlacedDocument(p) => Some(&mut p.placement),
_ => None,
}
}
/// The Placement Options of image or placed document `id` of `d`.
fn placement_of(d: &Document, id: NodeId) -> Option<PlacementOptions> {
match &d.node(id)?.kind {
NodeKind::Image(im) => Some(im.placement),
NodeKind::PlacedDocument(p) => Some(p.placement),
_ => None,
}
}
/// `id`, else the first selected image or placed document.
fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some());
let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
match placement_of(&st.doc, id) {
Some(_) => Ok(id),
None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)),
None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))),
}
}
/// Per linked image in the layers of `st`: its file's status, where it was found when away from
/// its path, and whether the image shows its preview (each file probed once).
fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
@@ -681,7 +858,7 @@ fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, b
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, folder(st.path.as_deref()));
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
let preview = previewing(&st.doc, &g.key);
let preview = stale(&st.doc, g);
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
}
out
@@ -696,6 +873,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str {
.map_or("Image", |f| f.label)
}
/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size
/// (`pageWidth`, `pageHeight`, pt) instead of pixels.
fn document_row(p: &PlacedDocument) -> Value {
json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height })
}
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
fn status_rank(status: &str) -> usize {
["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4)
@@ -709,17 +892,17 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded")));
}
let statuses = statuses(st);
let mut images = vec![];
let mut objects = vec![];
st.doc.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
images.push((n, im));
if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) {
objects.push(n);
}
});
// Top first, as the Layers panel lists them.
images.reverse();
objects.reverse();
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
let mut rows = vec![];
for (n, im) in images {
for n in objects {
let (status, found, preview) = match statuses.get(&n.id) {
Some((s, f, p)) => (s.as_str(), f.as_deref(), *p),
None => ("embedded", None, false),
@@ -728,17 +911,21 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
if show != "all" && show != status {
continue;
}
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
let mut row = json!({
"id": n.id.0,
"name": name,
"linked": im.link.is_some(),
"status": status,
"format": format_label(&st.doc, im),
"pixelWidth": im.width,
"pixelHeight": im.height,
});
if let Some(l) = &im.link {
let (link, mut row) = match &n.kind {
NodeKind::Image(im) => {
(im.link.as_ref(), json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height }))
}
NodeKind::PlacedDocument(p) => (Some(&p.link), document_row(p)),
_ => continue,
};
let name = link.map_or_else(|| n.display_name(), |l| l.name().to_string());
if let Some(o) = row.as_object_mut() {
o.insert("id".into(), json!(n.id.0));
o.insert("name".into(), json!(name));
o.insert("linked".into(), json!(link.is_some()));
o.insert("status".into(), json!(status));
}
if let Some(l) = link {
row["path"] = json!(l.path);
if let Some(page) = l.page {
row["page"] = json!(page);
@@ -766,7 +953,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
fn go_to(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = image_param(st, p, "links.goTo")?;
let id = linked_param(st, p, "links.goTo")?;
let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default();
s.select(|_, sel| sel.set([id]))?;
Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] }))
@@ -775,9 +962,29 @@ fn go_to(s: &mut Session, p: &Value) -> Result<Value> {
fn embed(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.embed";
let st = s.doc()?;
let ids = images_param(st, p, C, |im| im.link.is_some())?;
let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind);
let linked = |id: &NodeId| match kind(id) {
Some(NodeKind::Image(im)) => im.link.is_some(),
Some(NodeKind::PlacedDocument(_)) => true,
_ => false,
};
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect());
if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) {
return Err(bad(C, format!("object {} is not an image or a placed document", id.0)));
}
if ids.is_empty() {
return Err(bad(C, "select the linked images or placed documents, or pass ids"));
}
// Placed documents become editable copies of their art: those whose file can be read.
let (mut documents, mut missing) = (vec![], vec![]);
for id in ids.iter().copied() {
let Some(NodeKind::PlacedDocument(p)) = kind(&id) else { continue };
let readable = st.doc.images.get(&p.key).is_some_and(|b| vectorcraft_doc::placed_document::full_bytes(p, b).is_some());
if readable { documents.push(id) } else { missing.push(id) }
}
let ids: Vec<NodeId> = ids.into_iter().filter(|id| matches!(kind(id), Some(NodeKind::Image(_)))).collect();
// Images showing their preview take their file's pixels first.
let (mut done, mut missing, mut reads) = (vec![], vec![], vec![]);
let (mut done, mut reads) = (vec![], vec![]);
for g in groups(&st.doc, Some(&ids)) {
if !previewing(&st.doc, &g.key) {
done.extend(g.ids);
@@ -794,21 +1001,20 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
_ => missing.extend(g.ids),
}
}
if !done.is_empty() {
if !done.is_empty() || !documents.is_empty() {
s.edit("Embed", |d, _| {
for (ids, read) in reads {
install(d, &ids, read, true)?;
}
for id in &done {
if let Some(n) = d.node_mut(*id)
&& let NodeKind::Image(im) = &mut n.kind
{
if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) {
im.link = None;
}
}
Ok(())
documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C))
})?;
}
done.extend(documents);
Ok(json!({ "embedded": ids_json(&done), "missing": ids_json(&missing) }))
}
@@ -862,41 +1068,77 @@ fn placement_json(o: PlacementOptions) -> Value {
json!({ "preserve": o.preserve.id(), "align": o.align.id(), "clip": o.clip })
}
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
let stamp = file_stamp(&l.path);
let folder_len = l.path.len().saturating_sub(l.name().len());
out["status"] = json!(status.as_str());
out["path"] = json!(l.path);
out["fileName"] = json!(l.name());
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
for (k, v) in [
("page", l.page.map(u64::from)),
("fileSize", stamp.map(|s| s.0).or(l.size)),
("modified", stamp.and_then(|s| s.1).or(l.modified)),
("created", file_created(&l.path)),
] {
if let Some(v) = v {
out[k] = json!(v);
}
}
}
/// Scale (% of 100%, given points per unit at 100%) and counter-clockwise rotation of `xf`.
fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) {
let [a, b, c, d, ..] = xf.as_coeffs();
// Counter-clockwise on the page (y points down). Adding 0 turns -0 into 0.
(json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]), json!((-b).atan2(a).to_degrees() + 0.0))
}
/// Link Info of placed document `id`.
fn placed_info(st: &crate::DocState, id: NodeId) -> Result<Value> {
let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?;
let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) };
let size = n.geometric_bounds().unwrap_or_default();
let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0));
let mut out = document_row(pl);
let fields = [
("id", json!(id.0)),
("name", json!(n.display_name())),
("linked", json!(true)),
("link", json!(pl.link.path)),
("width", json!(size.width())),
("height", json!(size.height())),
("scale", scale),
("rotation", rotation),
("placement", placement_json(pl.placement)),
];
if let Some(o) = out.as_object_mut() {
o.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
}
file_fields(st, id, &pl.link, &mut out);
Ok(out)
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.info";
let id = image_param(s.doc()?, p, C)?;
let id = linked_param(s.doc()?, p, C)?;
if image_of(&s.doc()?.doc, id).is_none() {
return placed_info(s.doc()?, id);
}
let mut out = super::place::image_info(s, &json!({ "id": id.0 }))?;
let st = s.doc()?;
let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
let (px, py) = file_pt(&st.doc, &im.key);
let [a, b, c, d, ..] = im.xf.as_coeffs();
out["effectivePpi"] = out["ppi"].take();
out["ppi"] = json!([72.0 / px, 72.0 / py]);
out["scale"] = json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]);
// Counter-clockwise on the page (y points down).
// Adding 0 turns -0 into 0.
out["rotation"] = json!((-b).atan2(a).to_degrees() + 0.0);
(out["scale"], out["rotation"]) = scale_rotation(im.xf, (px, py));
out["format"] = json!(format_label(&st.doc, im));
out["placement"] = placement_json(im.placement);
out["status"] = json!("embedded");
if let Some(l) = &im.link {
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
let stamp = file_stamp(&l.path);
let folder_len = l.path.len().saturating_sub(l.name().len());
out["status"] = json!(status.as_str());
out["path"] = json!(l.path);
out["fileName"] = json!(l.name());
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
for (k, v) in [
("page", l.page.map(u64::from)),
("fileSize", stamp.map(|s| s.0).or(l.size)),
("modified", stamp.and_then(|s| s.1).or(l.modified)),
("created", file_created(&l.path)),
] {
if let Some(v) = v {
out[k] = json!(v);
}
}
file_fields(st, id, l, &mut out);
}
Ok(out)
}
@@ -904,7 +1146,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.placementOptions";
let st = s.doc()?;
let ids = images_param(st, p, C, |_| true)?;
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect());
if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) {
return Err(bad(C, format!("object {} is not an image or a linked file", id.0)));
}
if ids.is_empty() {
return Err(bad(C, "select the images, or pass ids"));
}
let choice = |k: &str| -> Result<Option<&str>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
@@ -918,7 +1166,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "clip must be true or false"))?),
};
let first = ids.first().and_then(|id| image_of(&st.doc, *id)).map(|im| im.placement).unwrap_or_default();
let first = ids.first().and_then(|id| placement_of(&st.doc, *id)).unwrap_or_default();
if preserve.is_none() && align.is_none() && clip.is_none() {
return Ok(json!({ "placement": placement_json(first) }));
}
@@ -929,10 +1177,8 @@ fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
};
s.edit("Placement Options", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& let NodeKind::Image(im) = &mut n.kind
{
set(&mut im.placement);
if let Some(o) = d.node_mut(*id).and_then(placement_mut) {
set(o);
}
}
Ok(())
+5 -1
View File
@@ -29,6 +29,7 @@ pub(crate) mod gradient;
pub(crate) mod graph;
mod halftone;
pub mod help;
pub(crate) mod inline;
mod layer;
mod layerpanel;
pub mod links;
@@ -48,7 +49,7 @@ mod pathops;
mod patterncmds;
pub mod pdfcmds;
pub mod perspgrid;
mod place;
pub(crate) mod place;
pub mod plugin;
pub mod prefscmds;
pub mod print;
@@ -59,6 +60,7 @@ pub mod rasterfx;
mod recolor;
pub mod recovery;
mod select;
pub(crate) mod shaper;
pub(crate) mod slices;
mod stroke;
mod style;
@@ -217,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(colormgmt::specs());
v.extend(typemenu::specs());
v.extend(textedit::specs());
v.extend(inline::specs());
v.extend(textstyles::specs());
v.extend(fonts::specs());
v.extend(help::specs());
@@ -231,6 +234,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(docinfo::specs());
v.extend(panelcmds::specs());
v.extend(buildcmds::specs());
v.extend(shaper::specs());
v.extend(brushsym::specs());
v.extend(patterncmds::specs());
v.extend(prefscmds::specs());
+79 -13
View File
@@ -150,7 +150,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool}",
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
has_doc,
set_props
),
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
has_selection,
align
),
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt, bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
has_multi,
distribute_spacing
),
@@ -722,7 +722,8 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
return Err(bad("object.isolate", "only groups and layers can be isolated"));
}
st.isolation = Some(id);
let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
st.isolation = Some(isolation);
st.selection.clear();
st.revision += 1;
ok()
@@ -732,12 +733,39 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
super::distortcmds::finish_edit_text(st, i);
st.selection.set([i]);
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i);
st.selection.set([target]);
}
st.revision += 1;
ok()
}
/// The most data entries `object.setProps` takes at once.
const MAX_DATA: usize = 256;
/// `object.setProps`'s `data`: each key (a data-* attribute's name: letters, digits, `-`, `_`,
/// `.`) with its value, or `None` to remove it.
fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<String>)>> {
const C: &str = "object.setProps";
if m.len() > MAX_DATA {
return Err(bad(C, format!("data takes at most {MAX_DATA} keys at once")));
}
m.iter()
.map(|(k, v)| {
let k = k.strip_prefix("data-").unwrap_or(k);
if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || k == "name" || k.starts_with("vc-") {
return Err(bad(C, format!("data key `{k}`: letters, digits, -, _ and . (not name or vc-…)")));
}
let v = match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
};
Ok((k.to_string(), v))
})
.collect()
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
@@ -745,6 +773,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
None => None,
};
let data = match p.get("data") {
None | Some(Value::Null) => None,
Some(Value::Object(m)) => Some(data_param(m)?),
Some(v) => return Err(bad("object.setProps", format!("data must be an object of key: value, not {v}"))),
};
let knockout = match p.get("knockout") {
Some(v) => Some(
Knockout::from_value(v)
@@ -779,6 +812,24 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) {
n.knockout_shape = v;
}
if let Some(changes) = &data {
n.edit_attrs(|a| {
for (k, v) in changes {
match (a.data.iter().position(|(key, _)| key == k), v) {
(Some(i), Some(v)) => {
if let Some(e) = a.data.get_mut(i) {
e.1 = v.clone();
}
}
(Some(i), None) => {
a.data.remove(i);
}
(None, Some(v)) => a.data.push((k.clone(), v.clone())),
(None, None) => {}
}
}
});
}
}
Ok(())
})?;
@@ -813,9 +864,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
/// selection.
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key"))
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
let st = s.doc()?;
match str_param(p, "to") {
match align_to(s, p) {
Some("artboard") => {
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
@@ -840,7 +897,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
items_bounds(d, &ids, preview)
.into_iter()
.filter(|(id, _)| Some(*id) != key || str_param(p, "to") != Some("key"))
.filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key"))
.map(|(id, b)| {
let dx = match h {
Some("left") => r.x0 - b.x0,
@@ -926,22 +983,31 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let n = items.len();
let &[(_, first), .., (_, last)] = items.as_slice() else {
return Err(EngineError::Other("select two or more objects to distribute".into()));
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let gap = match p.get("spacing").and_then(Value::as_f64) {
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite());
let gap = match spacing {
Some(g) => g,
None => {
let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 };
let span = if horiz { last.x1 - first.x0 } else { last.y1 - first.y0 };
(span - items.iter().map(|i| size(&i.1)).sum::<f64>()) / (n - 1) as f64
}
};
let mut pos = start(&items[0].1);
let mut moves = vec![];
let mut pos = start(&first);
let mut deltas = vec![];
for (id, r) in &items {
let delta = pos - start(r);
moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }));
deltas.push((*id, pos - start(r)));
pos += size(r) + gap;
}
// With a spacing (aligning to a key object) the key object stays where it is: the others
// are spaced from it.
let key = s.doc()?.selection.key;
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
+53 -36
View File
@@ -2,7 +2,7 @@
//! Character/Paragraph attributes.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_doc::{Composer, NodeKind};
use vectorcraft_geom::{Rect, Vec2};
use super::*;
@@ -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, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), 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\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\")}",
"{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), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -180,6 +180,9 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector
// ---------- text ----------
/// Largest indent or paragraph spacing (points; Illustrator's limit).
const MAX_PARA: f64 = 1296.0;
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
@@ -219,10 +222,19 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"leadingModel",
"charAlign",
"burasagari",
"composer",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Err(bad(C, "nothing to change"));
}
let composer = match p.get("composer") {
None | Some(Value::Null) => None,
Some(v) => Some(match v.as_str() {
Some("singleLine") => Composer::SingleLine,
Some("everyLine") => Composer::EveryLine,
_ => return Err(bad(C, "composer must be \"singleLine\" or \"everyLine\"")),
}),
};
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?;
let mojikumi = match p.get("mojikumi") {
@@ -259,11 +271,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
}),
};
let range = super::typecmd::TextRange::parse(p, C)?;
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
for r in &mut t.runs {
let st = &mut r.style;
super::typecmd::style_chars(t, range, |st| {
if let Some(k) = kerning {
st.kerning = k;
}
@@ -297,38 +309,43 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align {
st.char_align = v;
}
}
let para = &mut t.para;
if let Some(v) = num("leftIndent") {
para.left_indent = v;
}
if let Some(v) = num("rightIndent") {
para.right_indent = v;
}
if let Some(v) = num("firstLineIndent") {
para.first_line_indent = v;
}
if let Some(v) = num("spaceBefore") {
para.space_before = v;
}
if let Some(v) = num("spaceAfter") {
para.space_after = v;
}
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
if let Some(v) = direction {
para.direction = v;
}
if let Some(v) = leading_model {
para.leading_model = v;
}
if let Some(v) = burasagari {
para.burasagari = v;
}
});
let span = super::typecmd::para_span(range, t);
t.edit_paras(span, |para| {
if let Some(v) = num("leftIndent") {
para.left_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("rightIndent") {
para.right_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("firstLineIndent") {
para.first_line_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("spaceBefore") {
para.space_before = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("spaceAfter") {
para.space_after = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
if let Some(v) = direction {
para.direction = v;
}
if let Some(v) = leading_model {
para.leading_model = v;
}
if let Some(v) = burasagari {
para.burasagari = v;
}
if let Some(v) = composer {
para.composer = v;
}
});
super::typecmd::refresh_bounds(t);
}
Ok(())
+2 -1
View File
@@ -67,6 +67,7 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) {
}
}
NodeKind::Image(im) => f(Ref::Image(&mut im.key)),
NodeKind::PlacedDocument(pl) => f(Ref::Image(&mut pl.key)),
NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)),
_ => {}
}
@@ -129,7 +130,7 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> {
/// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point
/// `nodes` at the names they got.
pub(super) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
let mut used = Used::default();
for n in nodes.iter_mut() {
refs(n, &mut |r| used.note(r));
+273
View File
@@ -0,0 +1,273 @@
//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one
//! locked object showing an artboard of the document, a [`PlacedDocument`].
//!
//! The object keeps the file's bytes (in the document's images, like image data) and the link; the
//! art it shows is read from them the way placing the file without Link reads it
//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link
//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands
//! ([`super::super::links`]) tell when the file changed and read it again.
//!
//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept
//! as the blob's [`ImageBlob::proxy`]: a save writes only the preview (unless Include Linked
//! Files), as for linked images. The file is read again when it is needed ([`read_file_again`]:
//! output, or the screen zoomed past the preview), while it is unchanged, and output falls back to
//! the preview with a warning when it can't be ([`full_documents`]).
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::Arc;
use vectorcraft_doc::links::hash_bytes;
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect};
use super::super::fileio;
use super::super::*;
/// The largest file placed (bytes).
pub(crate) const MAX_FILE: usize = 256 << 20;
/// The format ids of VectorCraft documents.
const NATIVE: [&str; 2] = ["vectorcraft", "template"];
/// The type a placed file's bytes are stored with.
const MIME: &str = "application/json";
/// Is `name` with `bytes` a VectorCraft document?
pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool {
fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id))
}
/// An artboard of a VectorCraft document, read as art to place.
pub(crate) struct DocumentArt {
/// The document, its layers taken: the art's resources.
pub doc: Document,
/// The art on the artboard (guides, hidden and template layers left out).
pub nodes: Vec<Node>,
/// The artboard the art is clipped to (`None`: the art's bounds are shown).
pub clip: Option<Rect>,
/// What is shown: the artboard, or the art's bounds.
pub frame: Rect,
pub warnings: Vec<String>,
}
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`.
pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<DocumentArt> {
let file = fileio::file_name(name);
if !is_native(name, bytes) {
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document")));
}
if bytes.len() > MAX_FILE {
return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20)));
}
let mut l = fileio::load(name, bytes)?;
// Its linked images show their files; its placed documents stay as they are.
super::super::links::resolve(&mut l.doc, path, false);
l.doc.drop_edit_modes();
let n = l.doc.artboards.len();
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?;
let layers = std::mem::take(&mut l.doc.layers);
let nodes = super::on_artboard(&layers, board);
let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) };
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
}
/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads).
fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
let mut children: Vec<Arc<Node>> = nodes.into_iter().map(Arc::new).collect();
if let Some(r) = clip {
children.insert(0, Arc::new(super::clip_path(NodeId(0), r)));
}
Node::new(NodeId(0), NodeKind::Group { children, clip: clip.is_some() })
}
/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents
/// ([`vectorcraft_doc::placed_document::set_loader`]).
pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?;
Some((a.doc, group(a.nodes, a.clip), a.frame))
}
/// `p`'s file read again, while it is the file it was read from (how the document model gets it
/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]).
pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
let bytes = fileio::read_file(&p.link.path).ok()?;
if p.link.hash.as_ref().is_some_and(|h| *h != hash_bytes(&bytes)) {
return None;
}
Some(bytes)
}
/// A VectorCraft document read for placing linked.
#[derive(Clone)]
pub(crate) struct Source {
/// The file's bytes, with their preview.
pub blob: ImageBlob,
/// The blob's key ([`key_for`]).
pub key: String,
/// What is shown, in the file's coordinates: the artboard or the art's bounds.
pub frame: Rect,
pub warnings: Vec<String>,
}
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
/// artboard `page` (its art's bounds when `bounding`).
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<Source> {
// The same document placed the same way is read once (placing it again is free).
let key = key_for(&hash_bytes(bytes), page, bounding);
if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() {
return Ok(s);
}
let a = document_art(name, bytes, path, page, bounding, cmd)?;
let mut blob = ImageBlob::new(MIME, bytes.to_vec());
let shown = PlacedDocument {
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
key: key.clone(),
bounding,
width: a.frame.width(),
height: a.frame.height(),
xf: Affine::IDENTITY,
placement: PlacementOptions::default(),
};
let art = group(a.nodes, a.clip);
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
// Drawing it needn't read it again.
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
let s = Source { blob, key, frame: a.frame, warnings: a.warnings };
let mut m = lock_memo();
if m.len() >= MEMO_SIZE {
m.remove(0);
}
m.push(s.clone());
Ok(s)
}
/// The most files remembered.
const MEMO_SIZE: usize = 32;
/// Files read lately for placing, by key.
static MEMO: std::sync::Mutex<Vec<Source>> = std::sync::Mutex::new(Vec::new());
fn lock_memo() -> std::sync::MutexGuard<'static, Vec<Source>> {
// Entries are only pushed and removed: a panic while held leaves it whole.
MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The preview of `art` (of document `doc`, showing `frame`): [`PREVIEW_PX_PER_PT`], at most
/// [`PREVIEW_MAX`] pixels a side; JPEG when opaque (inside its outer pixels), else PNG.
fn preview(doc: &Document, art: &Node, frame: Rect) -> Option<Vec<u8>> {
let k = PREVIEW_PX_PER_PT.min(PREVIEW_MAX as f64 / frame.width().max(frame.height()).max(1e-9));
let side = |v: f64| (v * k).round().clamp(1.0, PREVIEW_MAX as f64) as u32;
let (w, h) = (side(frame.width()), side(frame.height()));
let view = Affine::scale_non_uniform(w as f64 / frame.width().max(1e-9), h as f64 / frame.height().max(1e-9))
* Affine::translate((-frame.x0, -frame.y0));
let mut r = vectorcraft_render::Renderer::new();
let img = vectorcraft_render::placed_document::render_inside(&mut r, doc, &Arc::new(art.clone()), w as u16, h as u16, view)?;
// Premultiplied → straight.
let px = img
.pixels
.as_chunks::<4>()
.0
.iter()
.flat_map(|&[r, g, b, a]| {
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
[un(r), un(g), un(b), a]
})
.collect();
let rgba = image::RgbaImage::from_raw(w, h, px)?;
let mut out = vec![];
// Opaque but for the edge's anti-aliasing (an artboard drawn to whole pixels) and compositing's
// rounding: JPEG.
let inner = |x: u32, y: u32| x > 0 && y > 0 && x + 1 < w && y + 1 < h;
if rgba.enumerate_pixels().all(|(x, y, p)| p[3] >= 250 || !inner(x, y)) {
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode_image(&rgb).ok()?;
} else {
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
}
Some(out)
}
/// `s` stored in `d` (objects showing the same thing share one blob).
pub(crate) fn store(d: &mut Document, s: &Source) {
d.images.entry(s.key.clone()).or_insert_with(|| s.blob.clone());
}
/// A new placed document of `d` showing `s` at 100%, where its art lies in the file.
pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) -> Node {
store(d, s);
let placed = PlacedDocument {
link,
key: s.key.clone(),
bounding,
width: s.frame.width(),
height: s.frame.height(),
xf: Affine::translate((s.frame.x0, s.frame.y0)),
placement: PlacementOptions::default(),
};
Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))
}
/// `d` for output: the placed documents that keep only their previews get their files back,
/// else (their files gone or changed) output as their previews, with a warning.
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
let mut need: Vec<PlacedDocument> = vec![];
d.visit_placed(|_, p| {
if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) {
need.push(p.clone());
}
});
if need.is_empty() {
return (Cow::Borrowed(d), vec![]);
}
let mut out = d.clone();
let mut previews = BTreeSet::new();
for p in need {
let Some(blob) = d.images.get(&p.key) else { continue };
match vectorcraft_doc::placed_document::full_bytes(&p, blob) {
Some(bytes) => {
out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() });
}
None => {
previews.insert(p.key);
}
}
}
let mut warnings = vec![];
if !previews.is_empty() {
warnings.push(format!(
"{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)",
previews.len()
));
out.placed_as_previews(&previews);
}
(Cow::Owned(out), warnings)
}
/// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file
/// without Link gives (its resources joining `d`, renamed where `d` uses a name for something
/// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand.
pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) };
let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?;
let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob)
.ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?;
let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?;
let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?;
let mut nodes = a.nodes;
super::adopt::adopt(d, &a.doc, &mut nodes);
let mut children: Vec<Arc<Node>> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect();
if let Some(r) = a.clip {
children.insert(0, Arc::new(super::clip_path(d.alloc_id(), r)));
}
let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m);
let mut out = n.clone();
out.kind = art.kind;
let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
d.remove(id)?;
d.insert(par, index, out)?;
Ok(())
}
+44 -18
View File
@@ -16,13 +16,17 @@
//! (`crop: "bounding"`).
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]).
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
//! one locked object showing the artboard `page`, linked to the file and read again when it
//! changes ([`document`]); without Link, an editable copy of its art.
//!
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the
//! `place` tool) with several files, which emits one `file.place` per click or drag.
mod adopt;
pub(crate) mod adopt;
pub(crate) mod document;
mod text;
use std::sync::Arc;
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
@@ -94,6 +98,9 @@ enum Art {
Image(RasterImage),
/// A text file's text, placed as area type filling the natural box.
Text(String),
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
/// bounds (not the artboard) are shown.
Document(document::Source, LinkInfo, bool),
Vector {
/// The loaded document: the source of the art's resources.
src: Box<Document>,
@@ -157,6 +164,19 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
));
}
let crop = opts.crop != CropTo::Bounding;
// A VectorCraft document: placed linked from its file, else as an editable copy.
if matches!(format.id, "vectorcraft" | "template") {
let page = page as u32;
if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) {
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?;
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
let (natural, warnings) = (s.frame, s.warnings.clone());
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
}
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?;
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
}
if format.raster {
let img = fileio::raster_image(&src.bytes)?;
let (sx, sy) = pt_per_px(img.ppi);
@@ -176,7 +196,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
// Linked images (a native document's) show their files, found from its folder.
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
(l.doc, l.warnings)
}
@@ -190,22 +210,31 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
// The page placed is a PDF import's only one.
let index = if pdf { 0 } else { page - 1 };
let n = doc.artboards.len();
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| {
let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" };
bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)"))
})?;
// A PDF import has one layer per page; a native document's (or an EPS file's) art is
// whatever lies on the artboard.
let pages: Vec<&Arc<Node>> = if pdf { layers.get(index).into_iter().collect() } else { layers.iter().collect() };
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board));
(nodes, crop.then_some(board))
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
(on_artboard(pages, board), crop.then_some(board))
};
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
}
/// The placeable art of `layers` that lies on `board`.
fn on_artboard(layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board))
}
/// A clipping path of rectangle `r`, with id `id`.
fn clip_path(id: NodeId, r: Rect) -> Node {
let mut c = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut c.kind {
*clipping = true;
}
c
}
/// A visible, non-template layer.
fn placeable(l: &Node) -> bool {
l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })
@@ -243,15 +272,12 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
Node::new(d.alloc_id(), NodeKind::Image(im))
}
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
Art::Document(s, link, bounding) => document::node(d, &s, link, bounding),
Art::Vector { src, mut nodes, clip } => {
adopt::adopt(d, &src, &mut nodes);
let mut children: Vec<Arc<Node>> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect();
if let Some(r) = clip {
let mut c = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut c.kind {
*clipping = true;
}
children.insert(0, Arc::new(c));
children.insert(0, Arc::new(clip_path(d.alloc_id(), r)));
}
Node::new(d.alloc_id(), NodeKind::Group { children, clip: clip.is_some() })
}
+3
View File
@@ -118,6 +118,9 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium");
set.advanced.flattener_preset = preset.to_string();
}
// Placed documents print their files' art.
let (full, _) = super::place::document::full_documents(doc);
let doc = &*full;
// Raster effects print as images at the document's raster effects resolution.
let flat = super::rasterfx::flatten_raster_effects(doc);
let doc = flat.as_ref().unwrap_or(doc);
+9 -1
View File
@@ -36,7 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
toggle
),
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
cmd!(
"select.key",
"Set Key Object",
[],
None,
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
has_doc,
key
),
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
cmd!(
"select.anchorsMany",
+771
View File
@@ -0,0 +1,771 @@
//! Shaper compositions are groups of generated fills/strokes with transparent, editable source
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
use super::*;
use serde_json::{Value, json};
use std::{collections::BTreeMap, sync::Arc};
use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOURCES, STROKE_PREFIX, ShaperSpec};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{self as b, BuilderMap, Hit};
const MAX_SOURCES: usize = 64;
const MAX_POINTS: usize = 4096;
const MAX_EDITS: usize = 8192;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shaper.scribble",
"Shaper",
[],
None,
"{points: [[x,y]…], ids?: [id…] (default: nearby art), tolerance?: pt (4)} interior scribbles remove fills; scribbles through several regions merge with the origin fill; ending outside removes fills and boundary strokes; outside scribbles erase stroke pieces. Originals remain editable → {id, regions, strokes}",
has_doc,
scribble
),
cmd!(
"shaper.select",
"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}",
has_doc,
select
),
cmd!(
"shaper.release",
"Release Shaper Group",
["Object", "Shaper"],
None,
"{id? (default: selection)} restore the original paths → {ids}",
has_doc,
release
),
cmd!(
"shaper.expand",
"Expand Shaper Group",
["Object", "Shaper"],
None,
"{id? (default: selection)} keep the visible result as ordinary paths → {id}",
has_doc,
expand
),
]
}
fn source_group(n: &Node) -> Option<&Node> {
n.shaper.as_ref()?;
n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES))
}
fn sources(n: &Node) -> Vec<Arc<Node>> {
source_group(n).and_then(Node::children).cloned().unwrap_or_default()
}
fn finite(p: Point) -> bool {
p.x.is_finite() && p.y.is_finite() && p.x.abs() <= 1e9 && p.y.abs() <= 1e9
}
fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
if src.is_empty() || src.len() > MAX_SOURCES {
return Err(bad("shaper.scribble", "Shaper needs 1–64 source paths"));
}
let mut count = 0usize;
let mut shapes = vec![];
for (i, n) in src.iter().enumerate() {
let (mut path, rule) = b::node_outline(n).ok_or_else(|| bad("shaper.scribble", "use paths or compound paths"))?;
count = count.saturating_add(path.subpaths.iter().map(|s| s.segment_count()).sum::<usize>());
if count > 4096 || path.subpaths.iter().flat_map(|s| &s.anchors).any(|a| !finite(a.p) || !finite(a.h_in) || !finite(a.h_out)) {
return Err(bad("shaper.scribble", "source paths are too large for Shaper"));
}
if !n.visible {
path = PathData::default();
} else if !n.appearance.fill_paint().is_none() {
for sub in path.subpaths.iter_mut().filter(|s| !s.closed) {
sub.closed = po::encloses_area(sub);
}
}
shapes.push(po::Shape::new(path, rule, i as u64));
}
let arrangement = po::shape_builder(&shapes, true);
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
let regions = arrangement
.regions
.into_iter()
.filter_map(|r| {
let bounds = r.path.bounds()?;
let path = r.path.to_bezpath();
Some((r, path, bounds))
})
.collect();
let map = BuilderMap { shapes, regions, edges: bounded(arrangement.edges), lines: bounded(arrangement.lines) };
if map.regions.is_empty() && map.lines.is_empty() && map.edges.is_empty() {
return Err(bad("shaper.scribble", "these paths are too degenerate for Shaper"));
}
Ok(map)
}
fn relative(src: &[Arc<Node>], i: usize, p: Point) -> Point {
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
Point::new((p.x - b.x0) / b.width().max(1e-9), (p.y - b.y0) / b.height().max(1e-9))
}
fn absolute(src: &[Arc<Node>], i: usize, p: Point) -> Point {
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
Point::new(b.x0 + p.x * b.width(), b.y0 + p.y * b.height())
}
fn face_selector(map: &BuilderMap, src: &[Arc<Node>], i: usize) -> Option<FaceSelector> {
let (r, _, bounds) = map.regions.get(i)?;
let source = *r.sources.first()?;
let point = po::interior_point(&r.path).unwrap_or(bounds.center());
Some(FaceSelector { sources: r.sources.clone(), anchor: relative(src, source, point) })
}
fn selected_face(map: &BuilderMap, src: &[Arc<Node>], selector: &FaceSelector) -> Option<usize> {
let point = absolute(src, *selector.sources.first()?, selector.anchor);
map.regions
.iter()
.enumerate()
.filter(|(_, (r, _, _))| r.sources == selector.sources)
.min_by(|(_, a), (_, b)| {
let distance = |r: &b::Face| if r.1.winding(point) != 0 { 0.0 } else { r.2.center().distance(point) };
distance(a).total_cmp(&distance(b))
})
.map(|(i, _)| i)
}
fn piece_point(path: &PathData) -> Option<Point> {
use vectorcraft_geom::ParamCurve as _;
let sub = path.subpaths.first()?;
(sub.segment_count() > 0).then(|| sub.segment(sub.segment_count() / 2).eval(0.5))
}
fn pieces(map: &BuilderMap) -> Vec<&po::Shape> {
map.edges.iter().chain(&map.lines).map(|(p, _)| p).collect()
}
fn side_sources(map: &BuilderMap, p: &po::Shape) -> [Vec<usize>; 2] {
let mut sides = map.sides(p).map(|i| i.and_then(|i| map.regions.get(i)).map_or(vec![], |r| r.0.sources.clone()));
sides.sort();
sides
}
fn edge_selector(map: &BuilderMap, src: &[Arc<Node>], piece: &po::Shape) -> Option<EdgeEdit> {
let source = usize::try_from(piece.key).ok()?;
Some(EdgeEdit { source, sides: side_sources(map, piece), anchor: relative(src, source, piece_point(&piece.path)?), erase: true, paint: None })
}
fn selected_edge(map: &BuilderMap, src: &[Arc<Node>], edit: &EdgeEdit) -> Option<usize> {
let point = absolute(src, edit.source, edit.anchor);
pieces(map)
.iter()
.enumerate()
.filter(|(_, p)| p.key == edit.source as u64 && side_sources(map, p) == edit.sides)
.filter_map(|(i, p)| p.path.nearest(point).map(|(_, _, _, _, distance)| (i, distance)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|p| p.0)
}
fn face_edits<'a>(map: &BuilderMap, src: &[Arc<Node>], spec: &'a ShaperSpec) -> BTreeMap<usize, &'a FaceEdit> {
spec.faces.iter().filter_map(|e| selected_face(map, src, &e.selector).map(|i| (i, e))).collect()
}
fn fill_source(src: &[Arc<Node>], r: &po::Region) -> Option<usize> {
r.sources.iter().rev().copied().find(|i| src.get(*i).is_some_and(|s| s.visible && !s.appearance.fill_paint().is_none()))
}
/// Regions separated only by covered strokes are one visible face. A visible line
/// through a circle still separates two faces, even though their fills have the same source.
fn visible_components(map: &BuilderMap, src: &[Arc<Node>], edits: &BTreeMap<usize, &FaceEdit>) -> Vec<usize> {
fn root(parents: &[usize], mut i: usize) -> usize {
for _ in 0..parents.len() {
let Some(&next) = parents.get(i) else { return i };
if next == i {
return i;
}
i = next;
}
i
}
let owner = |i: usize| {
let edit = edits.get(&i);
if edit.is_some_and(|e| e.erase || e.paint.as_ref().is_some_and(|a| a.fill_paint().is_none())) {
return None;
}
edit.and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(src, &r.0)))
};
let mut parents: Vec<usize> = (0..map.regions.len()).collect();
for piece in pieces(map) {
let [Some(a), Some(b)] = map.sides(piece) else { continue };
let (Some(left), Some(right)) = (owner(a), owner(b)) else { continue };
let merge = |i| edits.get(&i).and_then(|e| e.merge);
let merged = merge(a).is_some() && merge(a) == merge(b);
let covered = left as u64 > piece.key && right as u64 > piece.key;
let unstroked = src.get(piece.key as usize).is_none_or(|n| !n.visible || n.appearance.stroke_paint().is_none());
if (left == right || merged) && (merged || covered || unstroked) {
let (a, b) = (root(&parents, a), root(&parents, b));
if let Some(parent) = parents.get_mut(a) {
*parent = b;
}
}
}
(0..parents.len()).map(|i| root(&parents, i)).collect()
}
fn fill_style(mut a: Appearance) -> Appearance {
a.items.retain(AppearanceItem::is_fill);
a
}
fn stroke_style(mut a: Appearance) -> Appearance {
a.items.retain(|i| !i.is_fill());
a
}
/// Join compatible strokes at two-way junctions. Branches stay separate; a line
/// crossing a circle must not turn into the circle's outline at the intersection.
fn join_strokes(nodes: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
const TOL: f64 = 1e-5;
type End = (usize, bool);
let mut buckets: BTreeMap<(i64, i64), Vec<(End, Point)>> = BTreeMap::new();
let key = |p: Point| finite(p).then(|| ((p.x / TOL).floor() as i64, (p.y / TOL).floor() as i64));
for (i, n) in nodes.iter().enumerate() {
let Some(sp) = n.path_data().filter(|p| p.subpaths.len() == 1).and_then(|p| p.subpaths.first()).filter(|p| !p.closed) else { continue };
for (last, a) in [(false, sp.anchors.first()), (true, sp.anchors.last())] {
if let Some(a) = a
&& let Some(key) = key(a.p)
{
buckets.entry(key).or_default().push(((i, last), a.p));
}
}
}
let mut connections: BTreeMap<End, End> = BTreeMap::new();
for entries in buckets.values() {
for &(end, p) in entries {
let Some((x, y)) = key(p) else { continue };
let mut near = vec![];
for dx in -1..=1 {
for dy in -1..=1 {
if let Some(entries) = buckets.get(&(x + dx, y + dy)) {
near.extend(entries.iter().filter(|(_, q)| p.distance(*q) <= TOL).map(|(end, _)| *end));
}
}
}
if near.len() != 2 {
continue;
}
let Some(other) = near.into_iter().find(|e| *e != end) else { continue };
let Some((a, b)) = nodes.get(end.0).zip(nodes.get(other.0)) else { continue };
if a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend {
connections.insert(end, other);
}
}
}
let mut seen = std::collections::BTreeSet::new();
let mut out = vec![];
// Open chains first, then closed loops. This avoids beginning in a chain's middle.
let starts = nodes
.iter()
.enumerate()
.flat_map(|(i, _)| [(i, false), (i, true)])
.filter(|e| !connections.contains_key(e))
.chain(nodes.iter().enumerate().map(|(i, _)| (i, false)));
for start in starts {
if seen.contains(&start.0) {
continue;
}
if let Some(node) = nodes.get(start.0).filter(|n| n.path_data().is_none_or(|p| p.subpaths.len() != 1)) {
seen.insert(start.0);
out.push(node.clone());
continue;
}
let mut end = start;
let mut prototype = start.0;
let mut path = vectorcraft_geom::SubPath::default();
loop {
if !seen.insert(end.0) {
break;
}
prototype = prototype.min(end.0);
let Some(mut next) = nodes.get(end.0).and_then(|n| n.path_data()).and_then(|p| p.subpaths.first()).cloned() else { break };
if end.1 {
next.reverse();
}
if path.anchors.is_empty() {
path = next;
} else {
if let Some((a, b)) = path.anchors.last_mut().zip(next.anchors.first()) {
*a = vectorcraft_geom::Anchor::with_handles(a.p, a.h_in, b.h_out);
}
path.anchors.extend(next.anchors.into_iter().skip(1));
}
let Some(&next) = connections.get(&(end.0, !end.1)) else { break };
if next == start {
if let Some(last) = path.anchors.pop()
&& let Some(first) = path.anchors.first_mut()
{
*first = vectorcraft_geom::Anchor::with_handles(first.p, last.h_in, first.h_out);
}
path.closed = true;
break;
}
end = next;
}
if let Some(mut node) = nodes.get(prototype).cloned() {
if let Some(p) = Arc::make_mut(&mut node).path_data_mut() {
*p = PathData::new(vec![path]);
}
out.push(node);
}
}
out
}
fn generated(d: &mut Document, src: &[Arc<Node>], spec: &ShaperSpec, map: &BuilderMap) -> Result<Vec<Arc<Node>>> {
if spec.faces.len() + spec.edges.len() > MAX_EDITS {
return Err(bad("shaper.scribble", "too many Shaper edits"));
}
let edits = face_edits(map, src, spec);
let mut fills: BTreeMap<(Option<u64>, usize), Vec<usize>> = BTreeMap::new();
for (i, (r, _, _)) in map.regions.iter().enumerate() {
let edit = edits.get(&i);
if edit.is_some_and(|e| e.erase) {
continue;
}
if edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, r)).is_none() {
continue;
}
let merge = edit.and_then(|e| e.merge);
fills.entry((merge, if merge.is_some() { 0 } else { i })).or_default().push(i);
}
let mut out = vec![];
for indices in fills.values() {
let Some(&i) = indices.first() else { continue };
let Some((region, _, _)) = map.regions.get(i) else { continue };
let edit = edits.get(&i);
let Some(source) = edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, region)).and_then(|i| src.get(i)) else { continue };
let path = po::unite_all(&indices.iter().filter_map(|i| map.regions.get(*i).map(|r| (&r.0.path, FillRule::NonZero))).collect::<Vec<_>>());
let style = edit.and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
let mut n = Node::path(d.alloc_id(), path, fill_style(style));
n.opacity = source.opacity;
n.blend = source.blend;
n.name = Some(format!("{FILL_PREFIX}{i}"));
out.push(Arc::new(n));
}
let edge_edits: BTreeMap<usize, &EdgeEdit> = spec.edges.iter().filter_map(|e| selected_edge(map, src, e).map(|i| (i, e))).collect();
let mut strokes = vec![];
for (i, piece) in pieces(map).into_iter().enumerate() {
let sides = map.sides(piece);
let merge = |r: Option<usize>| r.and_then(|r| edits.get(&r)).and_then(|e| e.merge);
let merged_inside = merge(sides[0]).is_some() && merge(sides[0]) == merge(sides[1]);
// An exiting scribble removes the exposed stroke around the deleted area.
// Its shared boundary with surviving art still closes that art's contour.
let erased_boundary =
sides.iter().any(Option::is_some) && sides.iter().all(|r| r.is_none_or(|r| edits.get(&r).is_some_and(|e| e.erase && e.erase_edges)));
let occluded = sides
.iter()
.all(|r| r.and_then(|r| map.regions.get(r)).and_then(|r| fill_source(src, &r.0)).is_some_and(|source| source as u64 > piece.key));
if merged_inside || erased_boundary || occluded || edge_edits.get(&i).is_some_and(|e| e.erase) {
continue;
}
let Some(source) = src.get(piece.key as usize) else { continue };
let style = edge_edits.get(&i).and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
if style.stroke_paint().is_none() {
continue;
}
let mut n = Node::path(d.alloc_id(), piece.path.clone(), stroke_style(style));
n.opacity = source.opacity;
n.blend = source.blend;
n.name = Some(format!("{STROKE_PREFIX}{i}"));
strokes.push(Arc::new(n));
}
out.extend(join_strokes(strokes));
Ok(out)
}
/// Re-evaluate only groups whose original art changed. Errors reach Session::edit's rollback.
pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
let mut ids = vec![];
d.walk(|n| {
if n.shaper.is_some() {
ids.push(n.id)
}
});
for id in ids {
let Some(current) = d.node(id).cloned() else { continue };
let Some(old) = before.node(id) else { continue };
let src = sources(&current);
let source_changed = sources(old) != src;
let output_changed = current.children().is_some_and(|children| {
children
.iter()
.skip(1)
.any(|n| old.children().and_then(|c| c.iter().find(|o| o.id == n.id)).is_some_and(|o| o.appearance != n.appearance))
});
if !source_changed && !output_changed {
continue;
}
if src.is_empty() {
d.remove(id)?;
continue;
}
let map = map_of(&src)?;
let mut spec = current.shaper.as_deref().cloned().unwrap_or_default();
let previous = sources(old);
if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) {
// Source insertion, deletion or reordering updates the index-based recipes.
let index = |i: usize| previous.get(i).and_then(|old| src.iter().position(|n| n.id == old.id));
spec.faces.retain_mut(|e| {
let Some(indices) = e.selector.sources.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
let paint_source = match e.paint_source {
Some(i) => {
let Some(i) = index(i) else { return false };
Some(i)
}
None => None,
};
e.selector.sources = indices;
e.selector.sources.sort();
e.paint_source = paint_source;
true
});
spec.edges.retain_mut(|e| {
let Some(source) = index(e.source) else { return false };
for side in &mut e.sides {
let Some(indices) = side.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
*side = indices;
side.sort();
}
e.sides.sort();
e.source = source;
true
});
}
if output_changed {
for n in current.children().into_iter().flatten().skip(1) {
let Some(o) = old.children().and_then(|c| c.iter().find(|o| o.id == n.id)) else { continue };
if o.appearance == n.appearance {
continue;
}
if let Some(i) = n.name.as_deref().and_then(|s| s.strip_prefix(FILL_PREFIX)).and_then(|s| s.parse::<usize>().ok()) {
let existing = face_edits(&map, &src, &spec);
let merge = existing.get(&i).and_then(|e| e.merge);
let components = visible_components(&map, &src, &existing);
let related: Vec<usize> = if let Some(m) = merge {
existing.iter().filter(|(_, e)| e.merge == Some(m)).map(|(i, _)| *i).collect()
} else {
components.iter().enumerate().filter(|(_, c)| Some(*c) == components.get(i)).map(|(i, _)| i).collect()
};
for i in related {
let Some(selector) = face_selector(&map, &src, i) else { continue };
if let Some(e) = spec.faces.iter_mut().rev().find(|e| e.selector == selector) {
e.paint = Some(n.appearance.clone());
} else {
spec.faces.push(FaceEdit {
selector,
erase: false,
erase_edges: false,
paint_source: None,
merge,
paint: Some(n.appearance.clone()),
});
}
}
} else if n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)) {
// One joined output may contain pieces from several original paths.
for piece in pieces(&map) {
let contained = piece
.path
.subpaths
.iter()
.flat_map(|s| &s.anchors)
.all(|a| n.path_data().and_then(|p| p.nearest(a.p)).is_some_and(|p| p.4 < 1e-5))
&& piece_point(&piece.path).is_some_and(|point| n.path_data().and_then(|p| p.nearest(point)).is_some_and(|p| p.4 < 1e-5));
if contained && let Some(mut e) = edge_selector(&map, &src, piece) {
e.erase = false;
e.paint = Some(n.appearance.clone());
spec.edges.push(e);
}
}
}
}
}
let mut children = current.children().and_then(|c| c.first()).cloned().into_iter().collect::<Vec<_>>();
let old_ids: BTreeMap<&str, NodeId> =
current.children().into_iter().flatten().skip(1).filter_map(|n| n.name.as_deref().map(|name| (name, n.id))).collect();
for mut n in generated(d, &src, &spec, &map)? {
if let Some(id) = n.name.as_deref().and_then(|name| old_ids.get(name)) {
Arc::make_mut(&mut n).id = *id;
}
children.push(n);
}
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
n.shaper = Some(Box::new(spec));
if let Some(ch) = n.children_mut() {
*ch = children;
}
}
Ok(())
}
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
let expand = supplied.is_none() && st.selection.objects.len() < 2;
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
let eligible: Vec<NodeId> = ids
.into_iter()
.filter(|id| {
st.doc.is_editable(*id)
&& st.doc.node(*id).is_some_and(|n| {
!matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
&& (n.shaper.is_some() || b::node_outline(n).is_some())
})
})
.collect();
let bounds = |id| st.doc.node(id).and_then(Node::geometric_bounds).map(|b| b.inflate(tolerance, tolerance));
let overlaps = |a: Rect, b: Rect| a.intersect(b).area() > 0.0;
let mut ids: Vec<NodeId> = eligible.iter().copied().filter(|id| bounds(*id).is_some_and(|b| overlaps(b, area))).collect();
if expand {
// A protruding line must still be split at the shape it passes through, even when
// the scribble itself never enters that shape. Include the connected neighbourhood.
loop {
let next: Vec<NodeId> = eligible
.iter()
.copied()
.filter(|id| {
!ids.contains(id)
&& bounds(*id).is_some_and(|bb| {
ids.iter()
.any(|other| st.doc.parent_of(*other) == st.doc.parent_of(*id) && bounds(*other).is_some_and(|b| overlaps(bb, b)))
})
})
.collect();
if next.is_empty() {
break;
}
ids.extend(next);
if ids.len() > MAX_SOURCES {
return Err(bad("shaper.scribble", "too many overlapping paths for Shaper"));
}
}
}
let ids = b::sorted_roots(&st.doc, &ids);
let parent = ids.first().and_then(|id| st.doc.position(*id)).map(|p| p.0);
if ids.iter().any(|id| st.doc.position(*id).map(|p| p.0) != parent) {
return Err(bad("shaper.scribble", "overlapping Shaper paths must be in the same layer or group"));
}
Ok(ids)
}
fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shaper.scribble";
let input = p
.get("points")
.and_then(Value::as_array)
.filter(|a| !a.is_empty() && a.len() <= MAX_POINTS)
.ok_or_else(|| bad(C, "points must contain 1–4096 coordinates"))?;
let pts = input
.iter()
.map(|v| point_param(&json!({"p": v}), "p").filter(|p| finite(*p)).ok_or_else(|| bad(C, "invalid point")))
.collect::<Result<Vec<_>>>()?;
let tolerance = f64_or(p, "tolerance", 4.0);
if !tolerance.is_finite() || !(0.01..=1000.0).contains(&tolerance) {
return Err(bad(C, "invalid tolerance"));
}
let step = (tolerance / 2.0).max(0.5);
let sample_count = pts.windows(2).map(|w| (w[0].distance(w[1]) / step).ceil()).sum::<f64>();
if sample_count > 65_536.0 {
return Err(bad(C, "scribble is too long"));
}
let points = b::sample_polyline(&pts, step);
let roots = candidates(s, p, &points, tolerance)?;
if roots.is_empty() {
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
}
let mut src = vec![];
let mut spec = ShaperSpec::default();
let mut next_merge = 1u64;
for id in &roots {
let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(previous) = n.shaper.as_deref() {
let offset = src.len();
let merge_offset = next_merge;
for mut e in previous.faces.clone() {
for i in &mut e.selector.sources {
*i = i.saturating_add(offset);
}
e.paint_source = e.paint_source.map(|i| i.saturating_add(offset));
e.merge = e.merge.map(|i| i.saturating_add(merge_offset));
next_merge = next_merge.max(e.merge.unwrap_or(0).saturating_add(1));
spec.faces.push(e);
}
for mut e in previous.edges.clone() {
e.source = e.source.saturating_add(offset);
for side in &mut e.sides {
for i in side {
*i = i.saturating_add(offset);
}
}
spec.edges.push(e);
}
src.extend(sources(n));
} else {
src.push(Arc::new(n.clone()));
}
}
let map = map_of(&src)?;
let edits = face_edits(&map, &src, &spec);
let filled = |i: usize| edits.get(&i).is_none_or(|e| !e.erase) && map.regions.get(i).and_then(|r| fill_source(&src, &r.0)).is_some();
let components = visible_components(&map, &src, &edits);
let faces: std::collections::BTreeSet<usize> =
points.iter().filter_map(|p| map.region_at(*p)).filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect();
let touched: Vec<usize> = components.iter().enumerate().filter(|(_, c)| faces.contains(c)).map(|(i, _)| i).collect();
let first = points.first().copied().unwrap_or(Point::ZERO);
let last = points.last().copied().unwrap_or(first);
let origin = map.region_at(first).filter(|i| filled(*i));
let exits = map.region_at(last).is_none();
let merge = origin.is_some() && faces.len() > 1 && !exits;
let paint_source =
origin.and_then(|i| edits.get(&i).and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(&src, &r.0))));
let origin_paint = origin.and_then(|i| edits.get(&i).and_then(|e| e.paint.clone()));
for &i in &touched {
if let Some(selector) = face_selector(&map, &src, i) {
spec.faces.push(FaceEdit {
selector,
erase: !merge,
erase_edges: exits,
paint_source,
merge: merge.then_some(next_merge),
paint: origin_paint.clone(),
});
}
}
let mut strokes = 0;
if touched.is_empty() {
let mut piece_ids = vec![];
for point in &points {
let index = match map.hit(*point, Some(tolerance)) {
Some(Hit::Line(i)) => Some(map.edges.len() + i),
Some(Hit::Edge(i)) => Some(i),
_ => None,
};
if let Some(i) = index
&& !piece_ids.contains(&i)
{
piece_ids.push(i);
}
}
for i in piece_ids {
if let Some(e) = pieces(&map).get(i).and_then(|p| edge_selector(&map, &src, p)) {
spec.edges.push(e);
strokes += 1;
}
}
}
if touched.is_empty() && strokes == 0 {
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
}
let id = s.edit("Shaper", |d, sel| {
let top = *roots.last().ok_or_else(|| bad(C, "no source art"))?;
let (parent, index, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let earlier = roots.iter().filter(|id| **id != top && d.position(**id).is_some_and(|p| p.1 < index)).count();
let source_id = d.alloc_id();
let mut source = Node::group(source_id, src.clone());
source.name = Some(SOURCES.into());
source.opacity = 0.0;
let mut children = vec![Arc::new(source)];
children.extend(generated(d, &src, &spec, &map)?);
let id = d.alloc_id();
let mut group = Node::group(id, children);
group.name = Some("Shaper Group".into());
group.shaper = Some(Box::new(spec));
for id in &roots {
d.remove(*id)?;
}
d.insert(parent, index.saturating_sub(earlier), group)?;
sel.set([id]);
Ok(id)
})?;
s.doc_mut()?.isolation = None;
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
}
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
let mut groups = vec![];
d.walk(|n| {
if n.shaper.is_some()
&& 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))
{
groups.push(n.id)
}
});
groups.last().copied()
}
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})) };
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
let target = if source {
sources(node)
.iter()
.rev()
.find(|n| {
b::node_outline(n)
.is_some_and(|(path, _)| path.to_bezpath().winding(point) != 0 || path.nearest(point).is_some_and(|(_, _, _, _, d)| d < 6.0))
})
.map(|n| n.id)
.unwrap_or(group)
} else if st.selection.contains(group) {
node.children()
.into_iter()
.flatten()
.skip(1)
.find(|n| {
n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX)) && n.path_data().is_some_and(|p| p.to_bezpath().winding(point) != 0)
})
.map_or(group, |n| n.id)
} else {
group
};
let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten();
s.select(|_, sel| sel.set([target]))?;
if let Some(id) = isolation {
s.doc_mut()?.isolation = Some(id);
}
Ok(json!({"id": target.0}))
}
fn target(s: &Session, p: &Value) -> Result<NodeId> {
let id = id_param(p, "id")
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
Ok(id)
}
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p)?;
let ids = s.edit("Release Shaper Group", |d, sel| {
let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?;
let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
d.remove(id)?;
let mut ids = vec![];
for (i, n) in original.into_iter().enumerate() {
ids.push(d.insert(parent, index + i, (*n).clone())?);
}
sel.set(ids.iter().copied());
Ok(ids)
})?;
s.doc_mut()?.isolation = None;
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p)?;
s.edit("Expand Shaper Group", |d, sel| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
n.shaper = None;
n.name = None;
if let Some(children) = n.children_mut()
&& children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES))
{
children.remove(0);
}
sel.set([id]);
Ok(())
})?;
s.doc_mut()?.isolation = None;
Ok(json!({"id": id.0}))
}
+2 -2
View File
@@ -70,8 +70,8 @@ pub fn specs() -> Vec<CommandSpec> {
impl LibraryFile for StyleLibrary {
const EXTS: &'static [&'static str] = &[STYLES_EXT];
fn read(text: &str, stem: &str) -> std::result::Result<Self, String> {
style_libs::read(text, stem)
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
style_libs::read(&String::from_utf8_lossy(bytes), stem)
}
fn name(&self) -> &str {
&self.name
+23 -15
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Swatch Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches or .gpl library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
always,
load
),
@@ -98,23 +98,24 @@ struct Extra<L> {
pub trait LibraryFile: Sized {
/// The extensions of its files (lower case).
const EXTS: &'static [&'static str];
/// Read a library file whose name without the extension is `stem` (an unnamed library's name).
fn read(text: &str, stem: &str) -> std::result::Result<Self, String>;
/// Read the bytes of a library file whose name without the extension is `stem` (an unnamed
/// library's name).
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String>;
fn name(&self) -> &str;
}
impl LibraryFile for SwatchLibrary {
const EXTS: &'static [&'static str] = LIBRARY_EXTS;
fn read(text: &str, stem: &str) -> std::result::Result<Self, String> {
palette_io::read(text, stem)
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
palette_io::read_bytes(bytes, stem)
}
fn name(&self) -> &str {
&self.name
}
}
/// The extensions of library files [`palette_io::read`] reads.
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl"];
/// The extensions of library files [`palette_io::read_bytes`] reads.
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"];
impl<L: LibraryFile> Libraries<L> {
pub fn user_dir(&self) -> Option<&str> {
@@ -133,7 +134,7 @@ impl<L: LibraryFile> Libraries<L> {
let Some(dir) = self.user_dir.clone() else { return };
for path in library_files(&dir, L::EXTS) {
let file = file_name(&path);
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue };
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue };
let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" };
self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) });
}
@@ -351,12 +352,11 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.load";
let (info, lib) = s.swatch_libraries.load(p, C, |bytes, file| {
// A library file, or a document whose swatches become the library.
match std::str::from_utf8(bytes).ok().filter(|t| palette_io::sniff(t)) {
Some(t) => palette_io::read(t, stem(file)).map_err(|e| bad(C, e)),
None => {
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
document_library(&doc, &[], stem(file).to_string(), C)
}
if palette_io::sniff_bytes(bytes) {
palette_io::read_bytes(bytes, stem(file)).map_err(|e| bad(C, e))
} else {
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
document_library(&doc, &[], stem(file).to_string(), C)
}
})?;
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
@@ -417,12 +417,17 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
} else {
s.edit("Add to Swatches", |d, _| {
let mut out = vec![];
// Each library group goes into one document group: the group of that name the document
// already had, or one made here.
let before = d.swatch_groups.len();
let mut placed: Vec<(&str, usize)> = vec![];
for (group, w) in fresh {
let name = d.free_swatch_name(&w.name);
let sw = Swatch { name: name.clone(), ..(*w).clone() };
match group {
Some(g) => {
let i = match d.swatch_groups.iter().position(|x| x.name == *g) {
let found = placed.iter().find(|(n, _)| *n == g).map(|&(_, i)| i);
let i = match found.or_else(|| d.swatch_groups.iter().take(before).position(|x| x.name == *g)) {
Some(i) => i,
None => {
let name = d.free_swatch_name(g);
@@ -430,6 +435,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
d.swatch_groups.len() - 1
}
};
if found.is_none() {
placed.push((g, i));
}
d.swatch_groups[i].swatches.push(sw);
}
None => d.swatches.push(sw),
+22 -6
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, TabAlign, TabStop};
use super::typecmd::{refresh_bounds, text_targets};
use super::typecmd::{TextRange, para_span, refresh_bounds, text_targets};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -13,12 +13,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Tab Stops",
["Window", "Tabs"],
None,
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?} replace the tab stops of the selected text",
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches)",
has_selection,
set
),
cmd!(query "text.tabs.get", "Tab Stops", [], None, "{ids?} → {stops} of the first selected text object", has_selection, get),
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?}", has_selection, |s, p| apply(s, p, vec![], "Clear All Tabs")),
cmd!(
query "text.tabs.get",
"Tab Stops",
[],
None,
"{ids?, start?: byte} → {stops} of the first selected text object (its paragraph at `start`; default the first)",
has_selection,
get
),
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?, start?: byte, end?: byte}", has_selection, |s, p| apply(
s,
p,
vec![],
"Clear All Tabs"
)),
]
}
@@ -54,11 +67,13 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
let ids = text_targets(s, p, "text.tabs.set")?;
let range = TextRange::parse(p, "text.tabs.set")?;
let n = stops.len();
s.edit(label, |d, _| {
for id in &ids {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
t.para.tabs = stops.clone();
let span = para_span(range, t);
t.edit_paras(span, |pa| pa.tabs = stops.clone());
refresh_bounds(t);
}
}
@@ -69,9 +84,10 @@ fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let ids = text_targets(s, p, "text.tabs.get")?;
let range = TextRange::parse(p, "text.tabs.get")?;
let st = s.doc()?;
let tabs = ids.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.para.tabs.clone()),
Some(NodeKind::Text(t)) => Some(t.para_at(para_span(range, t).map_or(0, |r| r.start)).tabs.clone()),
_ => None,
});
Ok(json!({ "stops": serde_json::to_value(tabs.unwrap_or_default()).unwrap_or(Value::Null) }))
+25 -5
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id, 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\"} style a character range (runs are split at the range ends) → {id, runs}",
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, placeholder?: bool (placeholder text instead, as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
@@ -135,6 +135,13 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
let insert = str_param(p, "insert").unwrap_or("").to_string();
let caret = s.edit("Typing", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
// Paragraph styles follow the edit: a split paragraph's new one continues its style, a
// merge keeps the first paragraph's.
let inserted: String = match &styled {
Some(r) => r.iter().map(|r| r.text.as_str()).collect(),
None => insert.clone(),
};
t.splice_paras(a, b, &inserted);
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r),
None => edit::replace_range(&mut t.runs, a, b, &insert),
@@ -191,7 +198,7 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
}
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"` (Character Alignment).
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"|"icfTop"|"icfBottom"` (Character Alignment).
pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::CharAlign>> {
use vectorcraft_doc::CharAlign;
let Some(v) = p.get("charAlign").filter(|v| !v.is_null()) else { return Ok(None) };
@@ -200,7 +207,14 @@ pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraf
Some("emBoxTop") => CharAlign::EmBoxTop,
Some("emBoxCenter") => CharAlign::EmBoxCenter,
Some("emBoxBottom") => CharAlign::EmBoxBottom,
_ => return Err(bad(cmd, "`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\" or \"emBoxBottom\"")),
Some("icfTop") => CharAlign::IcfTop,
Some("icfBottom") => CharAlign::IcfBottom,
_ => {
return Err(bad(
cmd,
"`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\", \"emBoxBottom\", \"icfTop\" or \"icfBottom\"",
));
}
}))
}
@@ -461,8 +475,9 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
runs: vec![TextRun { text, style, inline: None }],
para: super::create::new_type_para(),
paras: Vec::new(),
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
@@ -471,6 +486,10 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
cached_bounds: None,
cached_baselines: Vec::new(),
};
super::create::new_type_alignment(s, p, C, &mut t)?;
if !on_path {
t.area.fit = super::create::new_area_fit(s, p, C)?;
}
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
@@ -596,6 +615,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
xf: Affine::IDENTITY,
runs: head.clone(),
para: Default::default(),
paras: Vec::new(),
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
+56 -17
View File
@@ -66,7 +66,7 @@ macro_rules! style_cmds {
concat!($p, ".apply"),
concat!("Apply ", $what, " Style"),
&[],
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range)} → {count}",
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range: character styles style it, paragraph styles apply to the paragraphs it touches)} → {count}",
|s, p| apply(s, p, $kind),
true,
),
@@ -179,7 +179,7 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb
}
}
/// How many runs (char) or text objects (para) use `name`.
/// How many runs (char) or paragraphs (para) use `name`.
fn uses(d: &Document, kind: Kind, name: &str) -> usize {
let target = (name != kind.normal()).then_some(name);
let mut n = 0;
@@ -187,7 +187,7 @@ fn uses(d: &Document, kind: Kind, name: &str) -> usize {
if let NodeKind::Text(t) = &node.kind {
n += match kind {
Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(),
Kind::Para => usize::from(t.para.style_name.as_deref() == target),
Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(),
};
}
});
@@ -210,9 +210,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
}
}
Kind::Para => {
if t.para.style_name == target {
t.para = restyle(&t.para, old, new);
changed = true;
for pa in t.para_styles_mut() {
if pa.style_name == target {
*pa = restyle(pa, old, new);
changed = true;
}
}
}
}
@@ -231,12 +233,11 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()),
};
let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) };
let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
Ok(Some(match kind {
Kind::Char => {
let at = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at))
}
Kind::Para => to_attrs(&t.para),
Kind::Char => to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at)),
// The paragraph at `start`.
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)),
}))
}
@@ -261,6 +262,26 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, V
}
}
/// The attributes of a new style made from nothing (no `attrs`, no text selected): while the
/// interface is in Japanese, new type's em box top-to-top leading (paragraph styles) and em box
/// centre alignment (character styles); else none (the defaults).
fn new_style_attrs(s: &Session, kind: Kind) -> Map<String, Value> {
if !s.japanese_interface() {
return Map::new();
}
let (set, default) = match kind {
Kind::Char => {
let st = CharStyle { char_align: vectorcraft_doc::CharAlign::EmBoxCenter, ..CharStyle::default() };
(to_attrs(&st), to_attrs(&CharStyle::default()))
}
Kind::Para => {
let st = ParaStyle { leading_model: vectorcraft_doc::LeadingModel::EmBoxTop, ..ParaStyle::default() };
(to_attrs(&st), to_attrs(&ParaStyle::default()))
}
};
set.into_iter().filter(|(k, v)| default.get(k) != Some(v)).collect()
}
// ---------- commands ----------
fn list(s: &mut Session, kind: Kind) -> Result<Value> {
@@ -275,7 +296,14 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let c = kind.cmd("new");
let attrs = match attrs_param(p, kind, "new")? {
Some(a) => a,
None => selection_attrs(s, p, kind)?.unwrap_or_default(),
None => match selection_attrs(s, p, kind)? {
Some(a) => a,
None => {
let a = new_style_attrs(s, kind);
s.note_journal("attrs", Value::Object(a.clone()));
a
}
},
};
let d = &s.doc()?.doc;
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
@@ -302,8 +330,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let normal = kind.attrs(d, kind.normal()).unwrap_or_default();
let clear = bool_or(p, "clearOverrides", false);
let style_name = (name != kind.normal()).then(|| name.clone());
// A Type tool range (character styles only) or whole text objects.
let range = id_param(p, "id").filter(|_| kind == Kind::Char && (p.get("start").is_some() || p.get("end").is_some()));
// A Type tool range or whole text objects.
let text_range = super::typecmd::TextRange::parse(p, &c)?;
let range = id_param(p, "id").filter(|_| text_range.is_some());
let ids = match range.or_else(|| id_param(p, "id")) {
Some(id) => vec![id],
None => text_targets(s, p, &c)?,
@@ -349,7 +378,17 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
return Err(e);
}
}
Kind::Para => t.para = para_fn(&t.para)?,
Kind::Para => {
let mut v = t.paragraph_styles();
let span = match (range, text_range) {
(Some(_), Some(r)) => r.paras(t),
_ => 0..v.len(),
};
for st in v.iter_mut().take(span.end).skip(span.start) {
*st = para_fn(st)?;
}
t.set_paragraph_styles(v);
}
}
refresh_bounds(t);
}
@@ -420,7 +459,7 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
};
match kind {
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
Kind::Para => fix(&mut t.para.style_name),
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
}
}
Ok(())
@@ -445,7 +484,7 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
};
match kind {
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
Kind::Para => fix(&mut t.para.style_name),
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
}
}
Ok(())
+44 -14
View File
@@ -75,14 +75,24 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
});
// The story: live frames' runs, plus the text of frames deleted by this edit.
let mut story: Vec<TextRun> = vec![];
let mut sources: Vec<TextObject> = vec![];
let mut later_wins = vec![];
let mut live = vec![];
for id in &thread {
match (text_of(doc, *id), text_of(before, *id)) {
(Some(t), _) => {
(Some(t), old) => {
story.extend(t.runs.iter().cloned());
// A frame whose first paragraph was just restyled gives the paragraph it
// shares with the frame before it that style.
later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0)));
sources.push(t.clone());
live.push(*id);
}
(None, Some(old)) => story.extend(old.runs.iter().cloned()),
(None, Some(old)) => {
story.extend(old.runs.iter().cloned());
later_wins.push(false);
sources.push(old.clone());
}
(None, None) => {}
}
}
@@ -92,16 +102,33 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
continue;
}
let source_refs: Vec<&TextObject> = sources.iter().collect();
let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins);
vectorcraft_text::edit::normalize(&mut story);
if live.len() >= 2 {
// Threaded frames keep their size: Auto Size is off for them (as in Illustrator).
for id in &live {
if text_of(doc, *id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight)
&& let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
{
t.area.fit = vectorcraft_doc::AreaFit::None;
}
}
}
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story);
for (id, runs) in live.iter().zip(parts) {
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
&& t.runs != runs
{
t.runs = runs;
refresh_bounds(t);
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, &paras);
for (id, part) in live.iter().zip(parts) {
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) {
let mut next = (**t).clone();
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
t.runs = next.runs;
t.para = next.para;
t.paras = next.paras;
refresh_bounds(t);
}
}
}
if live.len() >= 2 {
@@ -183,15 +210,17 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
continue;
}
let mut story: Vec<TextRun> = vec![];
for id in &thread {
if let Some(t) = text_of(d, *id) {
story.extend(t.runs.iter().cloned());
}
let sources: Vec<TextObject> = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect();
for t in &sources {
story.extend(t.runs.iter().cloned());
}
let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::<Vec<_>>(), &[]);
let style = story.first().map(|r| r.style.clone()).unwrap_or_default();
let first_para = paras.first().cloned().unwrap_or_default();
for id in &out {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
t.runs = vec![TextRun { text: String::new(), style: style.clone() }];
t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }];
t.set_all_paras(first_para.clone());
refresh_bounds(t);
}
}
@@ -200,6 +229,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
{
vectorcraft_text::edit::normalize(&mut story);
t.runs = story;
t.set_paragraph_styles(paras);
}
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
d.text_threads[i] = keep;
+335 -32
View File
@@ -4,7 +4,7 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area Type Options…",
["Type"],
None,
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)",
has_selection,
area_options
),
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
has_doc,
set_style
),
@@ -97,13 +97,110 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
}
/// Recompute the layout caches (bounds and baselines) after a text edit.
/// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing
/// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
/// step as the edit.
pub(crate) fn refresh_bounds(t: &mut TextObject) {
// Edits that changed the paragraph count keep one paragraph style per paragraph.
t.normalize_paras();
auto_height(t);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
t.cached_bounds = Some(lay.bounds);
t.cached_baselines = lay.baselines();
}
/// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one
/// subpath, as large as its bounds) of horizontal type in one row, with some height.
fn auto_height_frame(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
use vectorcraft_geom::Shape;
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None };
if t.area.fit != vectorcraft_doc::AreaFit::AutoHeight || t.vertical || t.area.rows > 1 || frame.subpaths.len() != 1 {
return None;
}
let b = frame.bounds()?;
let area = frame.to_bezpath().area().abs();
let finite = [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite());
(finite && b.height() > 1e-6 && b.width() > 1e-6 && (area - b.area()).abs() <= b.area() * 1e-6).then_some(b)
}
/// Auto Size: move the bottom of a rectangular area type frame to just below its last line (plus
/// the inset), so the frame's height follows the text. Text in several columns gets the least
/// height (to 0.01 pt) at which it fits. True when the frame changed.
fn auto_height(t: &mut TextObject) -> bool {
use vectorcraft_geom::Shape;
let Some(b) = auto_height_frame(t) else { return false };
let db = vectorcraft_text::FontDb::global();
// Lay out in a frame as tall as the canvas allows: everything the width lets through flows.
let tall = (crate::MAX_COORD - b.y0).min(1.0e5);
if tall <= 1.0 {
return false;
}
let mut probe = t.clone();
let with_size = |probe: &mut TextObject, w: f64, h: f64| {
if let vectorcraft_doc::TextKind::Area { frame } = &mut probe.kind {
*frame = PathData::from_bezpath(&vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + w, b.y0 + h).to_path(0.1));
}
};
// Columns: all the text in one column as wide as each of them is surely tall enough.
let cols = t.area.columns.max(1);
let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() };
with_size(&mut probe, col_w, tall);
probe.area.columns = 1;
let lay = vectorcraft_text::layout(db, &probe);
let inset = t.area.inset.max(0.0);
let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false };
// The layout's fit test is `baseline + descent <= bottom + 0.01`: the last line fits exactly.
let mut h = (bottom + inset - b.y0).clamp(1.0, tall);
if cols > 1 {
// The least height (to 0.01 pt) at which the columns hold the text.
probe.area.columns = cols;
let fits = |probe: &mut TextObject, h: f64| {
with_size(probe, b.width(), h);
!vectorcraft_text::layout(db, probe).overflow
};
if !fits(&mut probe, h) {
return false;
}
let (mut lo, mut hi) = (1.0, h);
for _ in 0..24 {
if hi - lo <= 0.01 {
break;
}
let mid = (lo + hi) * 0.5;
if fits(&mut probe, mid) { hi = mid } else { lo = mid }
}
h = hi;
}
if (h - b.height()).abs() <= 1e-6 {
return false;
}
// Scale the frame about its top edge, keeping its anchors (and their order) as they are.
let a = Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, h / b.height()) * Affine::translate((0.0, -b.y0));
let vectorcraft_doc::TextKind::Area { frame } = &mut t.kind else { return false };
let mut next = frame.clone();
next.transform(a);
if next.bounds().is_none_or(|nb| ![nb.x0, nb.y0, nb.x1, nb.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD)) {
return false;
}
*frame = next;
true
}
/// Recompute the layout bounds cache for the text among `ids` (and their descendants) that lacks
/// it. Pasted or imported type arrives without the cache (not serialized), so selection boxes, the
/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough
/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off.
pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) {
for id in text_ids(d, ids) {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
&& t.cached_bounds.is_none()
{
refresh_bounds(t);
}
}
}
/// Text objects among `ids` and their descendants.
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
@@ -133,7 +230,16 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
let NodeKind::Text(t) = &n.kind else { continue };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut children = vec![];
for g in &lay.glyphs {
for (gi, g) in lay.glyphs.iter().enumerate() {
// Inline graphics become instances of their symbols, in place.
if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi)
&& let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref())
{
let id = d.alloc_id();
let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol);
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
continue;
}
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
if path.is_empty() {
continue;
@@ -174,6 +280,60 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
Ok(t)
}
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
/// paragraphs it touches. Without either, the command applies to all the text.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct TextRange {
start: Option<usize>,
end: Option<usize>,
}
impl TextRange {
/// `start`/`end` of `p`, if either is given (non-negative integers).
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Option<Self>> {
let get = |k: &str| -> Result<Option<usize>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
v.as_u64().map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, format!("`{k}` must be a byte offset")))
}
}
};
let (start, end) = (get("start")?, get("end")?);
Ok((start.is_some() || end.is_some()).then_some(Self { start, end }))
}
/// The byte range in `t`, clamped and ordered.
pub(crate) fn bytes(&self, t: &TextObject) -> (usize, usize) {
let len = vectorcraft_text::edit::runs_len(&t.runs);
let a = self.start.unwrap_or(0).min(len);
let b = self.end.unwrap_or(len).min(len);
(a.min(b), a.max(b))
}
/// The paragraphs of `t` the range touches.
pub(crate) fn paras(&self, t: &TextObject) -> std::ops::Range<usize> {
let (a, b) = self.bytes(t);
t.paragraphs_in(a, b)
}
}
/// Paragraphs of `t` an optional range touches (None: every paragraph).
pub(crate) fn para_span(range: Option<TextRange>, t: &TextObject) -> Option<std::ops::Range<usize>> {
range.map(|r| r.paras(t))
}
/// Apply `f` to the character styles of `range` of `t` (None: every run).
pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: impl FnMut(&mut vectorcraft_doc::CharStyle)) {
match range {
None => t.runs.iter_mut().for_each(|r| f(&mut r.style)),
Some(r) => {
let (a, b) = r.bytes(t);
if a < b {
vectorcraft_text::edit::style_range(&mut t.runs, a, b, f);
}
}
}
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
let ids = text_targets(s, p, "text.setText")?;
@@ -181,7 +341,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let style = t.first_style();
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }];
// Every paragraph of the new text takes the first paragraph's attributes.
t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text);
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }];
refresh_bounds(t);
}
Ok(())
@@ -238,14 +400,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(C, "nothing to change"));
}
let range = TextRange::parse(p, C)?;
let ids = text_targets(s, p, C)?;
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let before = protect.then(|| t.runs.clone());
for r in &mut t.runs {
let st = &mut r.style;
style_chars(t, range, |st| {
if let Some(f) = &font {
st.font_family = f.clone();
}
@@ -267,12 +429,13 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &features {
st.features = f.clone();
}
}
});
if let Some(before) = before {
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
}
if let Some(j) = justify {
t.para.justify = j;
let span = para_span(range, t);
t.edit_paras(span, |pa| pa.justify = j);
}
refresh_bounds(t);
}
@@ -292,10 +455,18 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje
}
_ => false,
};
let height = |t: &TextObject| auto_height_frame(t).map(|b| b.height());
let was = height(t);
if !(f(t) && within(t)) {
t.kind = before;
return false;
}
// Setting the height by hand turns Auto Size off (as in Illustrator); a new width keeps it.
if let (Some(a), Some(b)) = (was, height(t))
&& (a - b).abs() > 1e-6
{
t.area.fit = vectorcraft_doc::AreaFit::None;
}
refresh_bounds(t);
true
}
@@ -334,6 +505,60 @@ fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
reshape_area_with(t, |t| t.transform_area(a))
}
/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`,
/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent`
/// while the fit isn't Shrink Text).
pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result<Option<AreaFit>> {
let min = p.get("fitMinPercent").and_then(Value::as_f64);
let cur_min = match cur {
AreaFit::ShrinkText { min_percent } => Some(min_percent),
_ => None,
};
let fit = match p.get("fit") {
None | Some(Value::Null) => match (cur, min) {
(AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m },
_ => return Ok(None),
},
Some(Value::String(id)) => {
AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))?
}
Some(v) => match serde_json::from_value::<AreaFit>(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? {
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) },
f => f,
},
};
Ok(Some(match fit {
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) },
f => f,
}))
}
/// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as
/// its id and `fitMinPercent` beside it, the frame's `width` and `height`, and whether the text
/// `overflow`s its frame at `fitScale` (Shrink Text to Fit's factor; 1 unshrunk).
pub(crate) fn area_options_json(t: &TextObject) -> Result<Value> {
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
if let Some(o) = v.as_object_mut() {
o.insert("fit".into(), json!(t.area.fit.id()));
let min = match t.area.fit {
AreaFit::ShrinkText { min_percent } => min_percent,
_ => AreaFit::DEFAULT_MIN_PERCENT,
};
o.insert("fitMinPercent".into(), json!(min));
if let Some((w, h, _)) = area_size(t) {
o.insert("width".into(), json!(w));
o.insert("height".into(), json!(h));
}
o.insert("overflow".into(), json!(lay.overflow));
o.insert("fitScale".into(), json!(lay.fit_scale));
}
Ok(v)
}
/// The [`vectorcraft_doc::AreaOptions`] keys `text.areaOptions` merges as they are.
const AREA_KEYS: [&str; 7] = ["rows", "columns", "gutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign"];
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.areaOptions";
let ids: Vec<NodeId> = {
@@ -343,34 +568,35 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
let options = |s: &Session| -> Result<Value> {
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) {
o.insert("width".into(), json!(w));
o.insert("height".into(), json!(h));
}
Ok(v)
area_options_json(t)
};
let mut v = options(s)?;
let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0));
let (w, h) = (size("width"), size("height"));
let mut changed = w.is_some() || h.is_some();
let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default();
let fit = fit_param(p, cur.fit, C)?;
let mut v = serde_json::to_value(&cur).map_err(|e| EngineError::Other(e.to_string()))?;
let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0));
let (w, mut h) = (size("width"), size("height"));
let mut changed = w.is_some() || h.is_some() || fit.is_some();
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
for (k, val) in src {
if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") {
o.insert(k.clone(), val.clone());
changed = true;
}
for (k, val) in src.iter().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())) {
o.insert(k.clone(), val.clone());
changed = true;
}
}
if !changed {
return Ok(v);
return options(s);
}
let mut opts: vectorcraft_doc::AreaOptions = serde_json::from_value(v).map_err(|e| bad(C, e.to_string()))?;
opts.rows = opts.rows.clamp(1, 100);
opts.columns = opts.columns.clamp(1, 100);
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
opts.inset = opts.inset.clamp(0.0, 10_000.0);
opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0);
let finite = |x: f64| if x.is_finite() { x } else { 0.0 };
opts.gutter = finite(opts.gutter).clamp(0.0, 10_000.0);
opts.inset = finite(opts.inset).clamp(0.0, 10_000.0);
opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0);
opts.fit = fit.unwrap_or(cur.fit);
if opts.fit == AreaFit::AutoHeight {
// Auto Size sets the height (Illustrator's dialog turns the Height field off).
h = None;
}
s.edit("Area Type Options", |d, _| {
for id in &ids {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
@@ -426,7 +652,7 @@ mod area_tests {
NodeKind::Text(t) => t.para.clone(),
_ => panic!("text"),
};
// New type: Standard, as in Illustrator.
// New type: Standard.
assert_eq!(para(&s).burasagari, Burasagari::Standard);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err());
@@ -444,6 +670,73 @@ mod area_tests {
assert_eq!(old.burasagari, Burasagari::None);
}
/// #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
/// baseline.
#[test]
fn new_type_takes_the_japanese_defaults_while_the_interface_is_japanese() {
use vectorcraft_doc::{CharAlign, LeadingModel};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let made = |s: &mut Session, cmd: &str, p: Value| -> (LeadingModel, CharAlign) {
let id = s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap();
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (t.para.leading_model, t.runs[0].style.char_align),
_ => panic!("text"),
}
};
let roman = (LeadingModel::RomanBaseline, CharAlign::RomanBaseline);
let japanese = (LeadingModel::EmBoxTop, CharAlign::EmBoxCenter);
let point = json!({"x": 10, "y": 50, "text": "雅楽"});
assert!(!s.japanese_interface());
assert_eq!(made(&mut s, "text.create", point.clone()), roman, "headless, `auto`: the Roman defaults");
s.ui_language = Some("ja".into());
assert_eq!(made(&mut s, "text.create", point.clone()), japanese);
assert_eq!(made(&mut s, "text.create", json!({"x": 10, "y": 90, "text": "笙", "area": {"width": 100, "height": 50}})), japanese);
// Journaled: a replay in another language does the same.
let (_, logged) = s.journal.last().unwrap().clone();
assert_eq!((logged["leadingModel"].as_str(), logged["charAlign"].as_str()), (Some("emBoxTop"), Some("emBoxCenter")));
// Params override.
assert_eq!(
made(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "a", "leadingModel": "romanBaseline", "charAlign": "romanBaseline"})),
roman
);
assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "leadingModel": "middle"})).is_err());
// Area type in a path.
let path = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 100, "height": 80})).unwrap()["id"].clone();
assert_eq!(made(&mut s, "text.createInPath", json!({"path": path, "mode": "area", "text": "篳篥"})), japanese);
// A preference set to Japanese counts without a UI; another language doesn't.
s.ui_language = None;
s.prefs.interface_language = "ja".into();
assert_eq!(made(&mut s, "text.create", point.clone()), japanese);
s.ui_language = Some("en".into());
assert_eq!(made(&mut s, "text.create", point.clone()), roman);
// New styles made from nothing.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.ui_language = Some("ja".into());
let style = |s: &mut Session, kind: &str| {
let name = s.execute(&format!("{kind}.new"), &json!({})).unwrap()["name"].as_str().unwrap().to_string();
let list = s.execute(&format!("{kind}.list"), &json!({})).unwrap();
list["styles"].as_array().unwrap().iter().find(|st| st["name"] == name.as_str()).unwrap()["attrs"].clone()
};
assert_eq!(style(&mut s, "paraStyle"), json!({"leading_model": "emBoxTop"}));
assert_eq!(style(&mut s, "charStyle"), json!({"charAlign": "emBoxCenter"}));
s.ui_language = Some("en".into());
assert_eq!(style(&mut s, "paraStyle"), json!({}));
// Imported text keeps the Roman baseline.
s.ui_language = Some("ja".into());
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="10" y="50">雅楽</text></svg>"#;
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
let mut found = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
found.push((t.para.leading_model, t.runs[0].style.char_align));
}
});
assert_eq!(found, [roman]);
}
#[test]
fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() {
use vectorcraft_doc::LeadingModel;
@@ -463,7 +756,10 @@ mod area_tests {
s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap();
let t = text(&s);
assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop);
assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top");
// The fallback used without craft-fonts has no glyph metrics to move the first line.
if !vectorcraft_text::CRAFT_FONTS.is_empty() {
assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top");
}
assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop");
s.execute("edit.undo", &json!({})).unwrap();
assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none());
@@ -493,6 +789,12 @@ mod area_tests {
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap();
let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect();
assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]);
// The ideographic character face's top and bottom.
for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] {
s.execute("text.setFormat", &json!({"charAlign": key})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.char_align == want));
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key);
}
}
#[test]
@@ -574,7 +876,8 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
let cell = lay.frames.first()?;
let line = lay.lines.first()?;
let avail = cell.width() - 2.0 * t.area.inset - t.para.left_indent - t.para.right_indent;
let para = t.para_at(0);
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count()))
}
+32 -13
View File
@@ -140,7 +140,8 @@ fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl F
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let mut st = S::default();
let mut any = false;
for r in &mut t.runs {
// Inline graphics are art, not characters to rewrite.
for r in t.runs.iter_mut().filter(|r| r.inline.is_none()) {
let n = f(&r.text, &mut st);
if n != r.text {
r.text = n;
@@ -272,7 +273,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
let slack = size * 0.5;
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
let (sl, sr) = match t.para.justify {
let (sl, sr) = match t.para_at(0).justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
@@ -315,20 +316,33 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
breaks.sort_unstable();
breaks.dedup();
for b in breaks.into_iter().rev() {
// Each wrapped line becomes a paragraph with its paragraph's attributes.
if plain.as_bytes().get(b - 1) == Some(&b' ') {
if let Some((ri, bi)) = locate(&t.runs, b - 1) {
t.splice_paras(b - 1, b, "\n");
t.runs[ri].text.replace_range(bi..bi + 1, "\n");
}
} else if let Some((ri, bi)) = locate(&t.runs, b) {
t.runs[ri].text.insert(bi, '\n');
t.splice_paras(b, b, "\n");
// Never inside an inline graphic's run: at the end of the run before it, or a
// run of its own.
match t.runs.get(ri) {
Some(r) if r.inline.is_some() => match ri.checked_sub(1).and_then(|p| t.runs.get_mut(p)) {
Some(prev) if prev.inline.is_none() => prev.text.push('\n'),
_ => {
let style = t.runs.get(ri).map(|r| r.style.clone()).unwrap_or_default();
t.runs.insert(ri, TextRun::new("\n", style));
}
},
_ => t.runs[ri].text.insert(bi, '\n'),
}
}
}
// The point origin sits where the alignment anchors the first line.
let (x0, x1) = lay
.lines
.first()
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - t.para.left_indent - t.para.first_line_indent, l.avail.1 + t.para.right_indent));
let ox = match t.para.justify {
let p0 = t.para_at(0);
let (x0, x1) =
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
let ox = match p0.justify {
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
Justify::Right | Justify::JustifyRight => x1,
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
@@ -479,7 +493,7 @@ pub(crate) fn fill_with_placeholder(t: &mut TextObject) {
}
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
};
t.runs = vec![TextRun { text, style }];
t.runs = vec![TextRun { text, style, inline: None }];
refresh_bounds(t);
}
@@ -529,8 +543,13 @@ fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
match t.runs.last_mut() {
Some(r) => r.text.push_str(&text),
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }),
Some(r) if r.inline.is_none() => r.text.push_str(&text),
// After an inline graphic: a run of its own, in its style.
Some(r) => {
let style = r.style.clone();
t.runs.push(TextRun::new(text.clone(), style));
}
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default(), inline: None }),
}
refresh_bounds(t);
}
@@ -645,8 +664,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
fn strip_formatting(n: &mut vectorcraft_doc::Node) {
if let NodeKind::Text(t) = &mut n.kind {
let text = t.plain_text();
t.runs = vec![TextRun { text, style: CharStyle::default() }];
t.para = Default::default();
t.runs = vec![TextRun { text, style: CharStyle::default(), inline: None }];
t.set_all_paras(Default::default());
refresh_bounds(t);
}
if let Some(ch) = n.children_mut() {
+13 -1
View File
@@ -80,7 +80,19 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
v["anchors"] = json!(path.anchor_count());
v["closed"] = json!(path.is_closed());
}
NodeKind::Text(t) => v["text"] = json!(t.plain_text()),
NodeKind::Text(t) => {
v["text"] = json!(t.plain_text());
// Area type and type on a path: does the text fit (the red overflow "+")? Area type
// also reports its fit and Shrink Text's factor (1 unshrunk).
if !matches!(t.kind, vectorcraft_doc::TextKind::Point) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
v["overflow"] = json!(lay.overflow);
if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) {
v["fit"] = json!(t.area.fit.id());
v["fitScale"] = json!(lay.fit_scale);
}
}
}
_ => {}
}
if let Some(ch) = n.children() {
+109 -22
View File
@@ -153,6 +153,8 @@ pub struct DocState {
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
pub print_tiling: bool,
/// The rows open in the Layers panel (view state: not undoable; native files keep it).
pub layers_open: OpenRows,
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
pub last_perspective: Option<Value>,
@@ -160,11 +162,61 @@ pub struct DocState {
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// 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.
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
/// Counts the changes, so views can keep what they work out from the open rows.
generation: u64,
}
impl OpenRows {
/// The rows `saved` in a file, else `doc`'s top-level layers.
fn new(doc: &Document, saved: Option<Vec<NodeId>>) -> Self {
let ids = match saved {
Some(ids) => ids.into_iter().collect(),
None => doc.layers.iter().map(|l| l.id).collect(),
};
Self { ids, generation: 0 }
}
pub fn contains(&self, id: NodeId) -> bool {
self.ids.contains(&id)
}
/// Open or close row `id`.
pub fn set(&mut self, id: NodeId, open: bool) {
let changed = if open { self.ids.insert(id) } else { self.ids.remove(&id) };
self.generation += u64::from(changed);
}
/// Changes so far: the same number means the same open rows.
pub fn generation(&self) -> u64 {
self.generation
}
/// What a native file keeps of the open rows: those of `doc` that hold others, in id order;
/// `None` when they are the default (only the top-level layers).
pub fn saved(&self, doc: &Document) -> Option<Vec<NodeId>> {
let mut ids = vec![];
doc.walk(|n| {
if n.children().is_some() && self.ids.contains(&n.id) {
ids.push(n.id);
}
});
ids.sort_unstable();
let mut layers: Vec<NodeId> = doc.layers.iter().map(|l| l.id).collect();
layers.sort_unstable();
(ids != layers).then_some(ids)
}
}
impl DocState {
pub fn new(mut doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
// The saved view lives here while the document is open (saves write it back).
// The saved view and open Layers rows live here while the document is open (saves write
// them back).
let view = doc.last_view.take();
let saved_open = doc.layers_open.take();
let layers_open = OpenRows::new(&doc, saved_open);
let doc = Arc::new(doc);
Self {
saved_doc: doc.clone(),
@@ -190,6 +242,7 @@ impl DocState {
recovered: false,
recovery: None,
print_tiling: false,
layers_open,
last_perspective: None,
}
}
@@ -634,7 +687,7 @@ impl Default for Prefs {
object_highlighting: true,
transform_tools_guides: true,
construction_guides: true,
construction_angles: s("90° & 45° Angles"),
construction_angles: s(vectorcraft_tools::guides::DEFAULT_CONSTRUCTION_ANGLES),
anchor_path_labels: true,
measurement_labels: true,
spacing_guides: true,
@@ -763,6 +816,10 @@ pub struct Session {
pub style_libraries: cmd::stylelib::Libraries,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
/// `None` without one (headless), where an explicit `interfaceLanguage` preference counts.
/// Japanese gives new type the Japanese defaults ([`Session::japanese_interface`]).
pub ui_language: Option<String>,
/// Parameters the running top-level command resolved from the preferences or the clock, added
/// to its journal entry so a replay does the same ([`Session::note_journal`]).
journal_note: serde_json::Map<String, Value>,
@@ -797,6 +854,9 @@ impl Default for Session {
impl Session {
pub fn new() -> Self {
// Placed documents are read by the native format's loader.
vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document);
vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again);
Self {
docs: vec![],
active: None,
@@ -825,6 +885,7 @@ impl Session {
freeform_point: None,
style_libraries: Default::default(),
recent_urls: vec![],
ui_language: None,
journal_note: Default::default(),
note_depth: 1,
batch_stash: None,
@@ -900,6 +961,8 @@ impl Session {
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
/// selection boxes and hit testing would use the rough estimate until each text is edited.
fn refresh_text_bounds(doc: &mut Document) {
// Inline graphics in text take their size from their symbols' art (not saved either).
doc.resolve_inline_art();
let mut texts = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
@@ -946,6 +1009,7 @@ impl Session {
st.save_options = old.save_options.clone();
st.converted = old.converted;
st.view = old.view.clone();
st.layers_open = old.layers_open.clone();
let old = std::mem::replace(&mut self.docs[index], st);
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
@@ -996,6 +1060,12 @@ impl Session {
Ok(r)
}
/// Is the interface in Japanese? Then new type starts with em box top-to-top leading and em
/// box centre character alignment (#432).
pub fn japanese_interface(&self) -> bool {
self.ui_language.as_deref().unwrap_or(&self.prefs.interface_language).eq_ignore_ascii_case("ja")
}
/// Record `key: value` in the running top-level command's journal entry (or its interaction's
/// preview, or its step in a batch) unless its params give `key`: a value it resolved from the
/// preferences or the clock.
@@ -1086,27 +1156,32 @@ impl Session {
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
let result = match f(doc, &mut st.selection) {
Ok(v) => {
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Opacity-mask editing: the mask follows its art on the editing layer.
if st.doc.mask_edit.is_some() {
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
// Inline graphics in text follow their symbols.
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Opacity-mask editing: the mask follows its art on the editing layer.
if st.doc.mask_edit.is_some() {
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
}
// Threaded text re-flows when any of its frames changed.
if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
}
// Asset Export: assets let go of deleted art.
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
}
}
// Threaded text re-flows when any of its frames changed.
if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
}
// Asset Export: assets let go of deleted art.
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
}
}
},
Err(e) => Err(e),
};
match result {
@@ -1279,6 +1354,8 @@ mod tests_adjust;
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_areafit;
#[cfg(test)]
mod tests_assets;
#[cfg(test)]
mod tests_attributes;
@@ -1369,6 +1446,8 @@ mod tests_gradpanel;
#[cfg(test)]
mod tests_halftone;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_knockout;
@@ -1415,6 +1494,8 @@ mod tests_paintproxy;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_paragraphs;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_pathtype;
@@ -1439,6 +1520,8 @@ mod tests_perspgrid;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_placed_document;
#[cfg(test)]
mod tests_plugins;
#[cfg(test)]
mod tests_prefs;
@@ -1475,8 +1558,12 @@ mod tests_saveoptions;
#[cfg(test)]
mod tests_scalestrokes;
#[cfg(test)]
mod tests_shaper;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartguides;
#[cfg(test)]
mod tests_strokegeom;
#[cfg(test)]
mod tests_strokegradient;
+110 -2
View File
@@ -1,6 +1,7 @@
use serde_json::json;
use super::*;
use vectorcraft_geom::Point;
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
fn session() -> Session {
@@ -198,6 +199,37 @@ fn align_left() {
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
}
/// #541: with the Selection tool, a click on one object of the selection makes it the key object:
/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click
/// on the key again lets it go.
#[test]
fn a_click_sets_the_key_object_that_align_and_spacing_keep_still() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0);
let c = rect(&mut s, 300.0, 60.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let click = |s: &mut Session| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 110.0, 40.0), v).unwrap();
}
};
click(&mut s);
assert_eq!(s.doc().unwrap().selection.key, Some(b));
assert_eq!(s.doc().unwrap().selection.objects.len(), 3, "the selection stays");
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
// With a key object, aligning aligns to it (the Control bar and Properties name no target).
s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (100.0, 100.0, 100.0), "the others moved to the key");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 5, "bounds": "geometric"})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (85.0, 100.0, 125.0), "5 pt apart, the key still");
click(&mut s);
assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key again lets it go");
}
#[test]
fn selection_tool_drag_is_one_undo_step() {
let mut s = session();
@@ -664,7 +696,7 @@ fn snap_to_pixel_rounds_drawing_and_moves() {
}
#[test]
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
@@ -694,10 +726,86 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
"{:?}",
n.kind_label()
);
// A zig-zag over it deletes it.
// A zig-zag inside it clears its fill while retaining the editable original and stroke.
let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect();
stroke(&mut s, &zig);
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n);
let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap();
assert!(group.shaper.is_some());
assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none()));
assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none()));
}
#[test]
fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
let undo = s.doc().unwrap().history.undo.len();
for (kind, x, y) in [
(PointerKind::Down, 150.0, 100.0),
(PointerKind::Drag, 200.0, 105.0),
(PointerKind::Drag, 194.0, 200.0),
(PointerKind::Drag, 110.0, 191.0),
(PointerKind::Drag, 100.0, 100.0),
(PointerKind::Up, 150.0, 100.0),
] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
let id = *s.doc().unwrap().selection.objects.first().expect("recognized square");
assert!(matches!(
s.doc().unwrap().doc.node(id).unwrap().kind,
vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }
));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_some());
}
#[test]
fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() {
let v = ViewInfo { smart_guides: false, ..Default::default() };
for direction in [0.0_f64, 180.0] {
let mut s = session();
s.select_tool("shaper", v).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let corners: Vec<Point> = (0..3)
.map(|i| {
let angle = (direction + 7.0 - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
Point::new(200.0 + 70.0 * angle.cos(), 200.0 + 70.0 * angle.sin())
})
.collect();
let mut pts = vec![];
for i in 0..3 {
for j in 0..20 {
pts.push(corners[i] + (corners[(i + 1) % 3] - corners[i]) * (j as f64 / 20.0));
}
}
// Begin midway along an edge; the release closes the final segment.
pts.rotate_left(7);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y), v).unwrap();
for p in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x, p.y), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap();
let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle");
let node = s.doc().unwrap().doc.node(id).unwrap().clone();
let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else {
panic!("expected live triangle, got {:?}", node.kind_label());
};
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sin, cos) = direction.to_radians().sin_cos();
for (actual, expected) in [(a, cos), (b, sin), (c, -sin), (d, cos)] {
assert!((actual - expected).abs() < 1e-12, "direction {direction}, transform {xf:?}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &node);
}
}
#[test]
+242
View File
@@ -0,0 +1,242 @@
//! Area type fitting: Auto Size (the frame's height follows the text), Shrink Text to Fit, and
//! overflow reporting.
use serde_json::{Value, json};
use vectorcraft_doc::{AreaFit, NodeKind, TextKind, TextObject};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it.";
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn area(s: &mut Session, w: f64, h: f64, text: &str, extra: Value) -> NodeId {
let mut a = json!({"width": w, "height": h});
if let (Some(a), Some(e)) = (a.as_object_mut(), extra.as_object()) {
a.extend(e.clone());
}
let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": a, "text": text})).unwrap()["id"].as_u64().unwrap();
NodeId(id)
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
k => panic!("not text: {k:?}"),
}
}
fn frame(s: &Session, id: NodeId) -> Rect {
match text(s, id).kind {
TextKind::Area { frame } => frame.bounds().unwrap(),
k => panic!("not area type: {k:?}"),
}
}
fn overflows(s: &Session, id: NodeId) -> bool {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).overflow
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn area_options_set_and_report_the_fit() {
let mut s = session();
let id = area(&mut s, 120.0, 30.0, STORY, json!({}));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let q = s.execute("text.areaOptions", &json!({})).unwrap();
assert_eq!((q["fit"].as_str(), q["fitMinPercent"].as_f64()), (Some("none"), Some(50.0)));
assert_eq!((q["overflow"].as_bool(), q["fitScale"].as_f64()), (Some(true), Some(1.0)));
let n = undo_len(&s);
let r = s.execute("text.areaOptions", &json!({"fit": "shrinkText", "fitMinPercent": 20})).unwrap();
assert_eq!(undo_len(&s), n + 1);
assert_eq!((r["fit"].as_str(), r["fitMinPercent"].as_f64()), (Some("shrinkText"), Some(20.0)));
assert_eq!(r["overflow"], false, "{r}");
let f = r["fitScale"].as_f64().unwrap();
assert!((0.2..1.0).contains(&f), "{f}");
assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 20.0 });
assert_eq!(text(&s, id).runs[0].style.size, 12.0, "the stored size stays");
// The minimum alone, clamped; the object form; and bad values.
s.execute("text.areaOptions", &json!({"fitMinPercent": 3})).unwrap();
assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 10.0 });
s.execute("text.areaOptions", &json!({"fit": {"shrinkText": {"minPercent": 95}}})).unwrap();
assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 95.0 });
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["overflow"], true, "95 % is not enough");
assert!(s.execute("text.areaOptions", &json!({"fit": "squash"})).is_err());
assert!(s.execute("text.areaOptions", &json!({"fit": 7})).is_err());
// The reply's own keys go back in unchanged (the dialog sends everything).
let q = s.execute("text.areaOptions", &json!({})).unwrap();
let n = undo_len(&s);
let again = s.execute("text.areaOptions", &q).unwrap();
assert_eq!(again, q);
assert_eq!(undo_len(&s), n + 1);
// Undo back to no fit.
while text(&s, id).area.fit != AreaFit::None {
s.execute("edit.undo", &json!({})).unwrap();
}
assert!(overflows(&s, id));
}
#[test]
fn inspect_reports_overflow_and_fit_scale() {
let mut s = session();
let id = area(&mut s, 120.0, 30.0, STORY, json!({"fit": "shrinkText", "fitMinPercent": 10}));
s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap();
let doc = s.execute("document.inspect", &json!({})).unwrap();
let kids = doc["layers"][0]["children"].as_array().unwrap();
let node = kids.iter().find(|k| k["id"] == id.0).unwrap();
assert_eq!(node["overflow"], false);
assert_eq!(node["fit"], "shrinkText");
assert!(node["fitScale"].as_f64().unwrap() < 1.0);
let point = kids.iter().find(|k| k["id"] != id.0).unwrap();
assert!(point.get("overflow").is_none() && point.get("fitScale").is_none(), "point type never overflows");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.areaOptions", &json!({"fit": "none"})).unwrap();
let doc = s.execute("document.inspect", &json!({})).unwrap();
let node = doc["layers"][0]["children"].as_array().unwrap().iter().find(|k| k["id"] == id.0).unwrap().clone();
assert_eq!((node["overflow"].as_bool(), node["fitScale"].as_f64(), node["fit"].as_str()), (Some(true), Some(1.0), Some("none")));
}
#[test]
fn auto_height_follows_edits_in_the_same_undo_step() {
let mut s = session();
let id = area(&mut s, 200.0, 300.0, "One line", json!({"fit": "autoHeight"}));
let one = frame(&s, id);
assert!(one.height() < 30.0, "the frame fits one line: {one:?}");
assert_eq!((one.x0, one.y0, one.width()), (0.0, 0.0, 200.0), "top and width stay");
assert!(!overflows(&s, id));
let n = undo_len(&s);
s.execute("text.editRange", &json!({"id": id.0, "start": 8, "insert": format!(" {STORY}")})).unwrap();
assert_eq!(undo_len(&s), n + 1);
let grown = frame(&s, id);
assert!(grown.height() > one.height() * 2.0, "{grown:?}");
assert!(!overflows(&s, id), "the grown frame holds the text");
// Tight: a point less overflows.
let mut t = text(&s, id);
t.area.fit = AreaFit::None;
t.kind = TextKind::Area {
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 200.0, grown.height() - 1.0), 0.1)),
};
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Character changes resize it too.
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 3, "size": 40})).unwrap();
assert!(frame(&s, id).height() > grown.height());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frame(&s, id), grown);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frame(&s, id), one, "one undo restores the text and the frame");
assert_eq!(text(&s, id).plain_text(), "One line");
}
#[test]
fn auto_height_follows_typing_with_the_type_tool() {
let mut s = session();
s.prefs.auto_size_area_type = true;
// An empty frame (no placeholder text), so its first height is one line.
s.prefs.placeholder_text = false;
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for (kind, (x, y)) in [(PointerKind::Down, (100.0, 100.0)), (PointerKind::Drag, (300.0, 400.0)), (PointerKind::Up, (300.0, 400.0))] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
assert_eq!(text(&s, id).area.fit, AreaFit::AutoHeight, "the preference gives new area type Auto Size");
let empty = frame(&s, id);
assert!(empty.height() < 30.0, "{empty:?}");
let n = undo_len(&s);
for word in STORY.split_inclusive(' ') {
s.tool_text(word, v).unwrap();
}
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(text(&s, id).plain_text(), STORY);
assert_eq!(undo_len(&s), n + 1, "one typing step");
let typed = frame(&s, id);
assert!(typed.height() > empty.height() * 2.0, "{typed:?}");
assert!(!overflows(&s, id));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frame(&s, id), empty);
assert_eq!(text(&s, id).plain_text(), "");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(frame(&s, id), typed);
}
#[test]
fn auto_height_in_columns_and_by_hand() {
let mut s = session();
let id = area(&mut s, 300.0, 400.0, &STORY.repeat(3), json!({"fit": "autoHeight"}));
let one = frame(&s, id).height();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
// Height is ignored while Auto Size is on.
let r = s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 12, "height": 500})).unwrap();
let two = r["height"].as_f64().unwrap();
assert!(two < 500.0 && two > one * 0.5, "{one} → {two}");
assert_eq!(r["overflow"], false);
assert_eq!(r["fit"], "autoHeight");
// The least height the two columns fit: a point less overflows, and both columns are used.
let mut t = text(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.glyphs.iter().any(|g| g.origin.x > 160.0), "text flows into the second column");
t.area.fit = AreaFit::None;
t.kind = TextKind::Area {
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 300.0, two - 1.0), 0.1)),
};
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Dragging the bottom edge sets the height by hand: Auto Size goes off.
let b = frame(&s, id);
s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]], "dx": 0, "dy": 50})).unwrap();
assert_eq!(text(&s, id).area.fit, AreaFit::None);
assert!((frame(&s, id).height() - (b.height() + 50.0)).abs() < 1e-6);
s.execute("edit.undo", &json!({})).unwrap();
// A new width keeps it: the height follows the reflow.
s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 1], [0, 2]], "dx": 100, "dy": 0})).unwrap();
assert_eq!(text(&s, id).area.fit, AreaFit::AutoHeight);
assert!(frame(&s, id).height() < b.height());
// Point type has no frame to size.
let p = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [p]})).unwrap();
assert!(s.execute("text.areaOptions", &json!({"fit": "autoHeight"})).is_err(), "point type has no area options");
}
#[test]
fn shrink_text_round_trips_through_the_native_format() {
let mut s = session();
let id = area(&mut s, 120.0, 30.0, STORY, json!({"fit": "shrinkText", "fitMinPercent": 35}));
let doc = s.doc().unwrap().doc.clone();
let bytes = vectorcraft_format::save(&doc, false);
let back = vectorcraft_format::load(&bytes).unwrap();
let NodeKind::Text(t) = &back.node(id).unwrap().kind else { panic!() };
assert_eq!(t.area.fit, AreaFit::ShrinkText { min_percent: 35.0 });
}
#[test]
fn auto_height_follows_the_leading_model_and_character_alignment() {
let mut s = session();
let id = area(&mut s, 200.0, 300.0, STORY, json!({"fit": "autoHeight"}));
let roman = frame(&s, id);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
// Top-to-Top leading: the first line moves up to touch the frame, the frame follows.
s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap();
let em = frame(&s, id);
assert_ne!(em.height(), roman.height());
assert!(!overflows(&s, id), "the frame holds the text under emBoxTop");
// Mixed sizes aligned on the em box centre still fit exactly.
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 3, "size": 30})).unwrap();
s.execute("text.setFormat", &json!({"charAlign": "emBoxCenter"})).unwrap();
let mixed = frame(&s, id);
assert!(mixed.height() > em.height());
assert!(!overflows(&s, id));
let mut t = text(&s, id);
t.area.fit = AreaFit::None;
t.kind = TextKind::Area {
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 200.0, mixed.height() - 1.0), 0.1)),
};
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "tight");
}
+24
View File
@@ -190,3 +190,27 @@ fn svg_export_links_objects_and_import_reads_the_links_back() {
});
assert_eq!(urls, [Some("https://example.com/?a=1&b=2".to_string()), None], "the link wraps the path alone");
}
/// #522: an object's own data (SVG `data-*` attributes) is set with `object.setProps {data}`,
/// undone in one step, written to SVG and kept by the native format.
#[test]
fn object_data_is_set_saved_and_written_to_svg() {
let mut s = session();
let a = rect(&mut s, 10.0);
let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default();
let pair = |k: &str, v: &str| (k.to_string(), v.to_string());
run(&mut s, "object.setProps", json!({"data": {"pivot": "60,100", "z": 3}}));
assert_eq!(data(&s), [pair("pivot", "60,100"), pair("z", "3")], "values are text");
run(&mut s, "object.setProps", json!({"data": {"data-pivot": "1,2", "z": null}}));
assert_eq!(data(&s), [pair("pivot", "1,2")], "a data- prefix is optional; null removes a key");
run(&mut s, "edit.undo", json!({}));
assert_eq!(data(&s).len(), 2, "one step");
for bad in [json!({"data": {"vc-x": "1"}}), json!({"data": {"name": "x"}}), json!({"data": {"a b": "1"}}), json!({"data": "pivot"})] {
assert!(s.execute("object.setProps", &bad).is_err(), "{bad}");
}
let svg = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string();
assert!(svg.contains("data-pivot=\"60,100\" data-z=\"3\""), "{svg}");
let native = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let back = vectorcraft_format::load(&native).unwrap();
assert_eq!(back.node(a).unwrap().attrs, s.doc().unwrap().doc.node(a).unwrap().attrs);
}
+98 -1
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -669,6 +669,39 @@ fn image_trace_make_and_expand_and_color_presets() {
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
}
#[test]
fn image_trace_create_strokes_makes_stroked_centre_lines() {
let mut s = session();
// A 3 px wide horizontal line from x = 10 to 90 at y = 50, placed at 2× from (100, 100).
let r =
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut s, &r, Affine::translate((100.0, 100.0)) * Affine::scale(2.0));
let res = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}})).unwrap();
assert_eq!(res["paths"], 1);
let g = NodeId(res["id"].as_u64().unwrap());
let paths = traced_paths(&node(&s, g));
let line = &paths[0];
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
let sp = &line.path_data().unwrap().subpaths[0];
assert!(!sp.closed);
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}");
let t = node(&s, g).trace.unwrap();
assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0)));
// Expand keeps the stroked line.
s.execute("imageTrace.expand", &json!({})).unwrap();
assert!(node(&s, g).children().unwrap()[0].appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
// Create needs Fills or Strokes, and a stroke width.
s.execute("edit.undo", &json!({})).unwrap();
for p in [json!({"fills": false, "strokes": false}), json!({"strokes": true, "strokeWidth": 0}), json!({"strokeWidth": -3})] {
assert!(s.execute("imageTrace.make", &json!({"id": g.0, "params": p})).is_err(), "{p}");
}
}
#[test]
fn image_trace_presets_query_and_errors() {
let mut s = session();
@@ -704,3 +737,67 @@ fn image_trace_object_remembers_its_settings() {
let id = st.selection.in_paint_order(&st.doc)[0];
assert!(st.doc.node(id).unwrap().trace.is_none());
}
/// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals
/// overlapping) merges every region the drags touch into one shape whose outline is only the
/// merged area's border: no edge between merged regions stays in, as a stray line or a spur.
#[test]
fn shape_builder_merges_rosette_rings_cleanly() {
let mut s = session();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
let c = [400.0, 320.0];
let mut petals = vec![];
for k in 0..8 {
let id =
s.execute("shape.ellipse", &json!({"x": c[0] - 60.0, "y": c[1] - 172.0, "width": 120, "height": 324})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("object.rotate", &json!({"angle": 45.0 * f64::from(k), "origin": c})).unwrap();
petals.push(id);
}
s.execute("select.set", &json!({"ids": petals})).unwrap();
// Drags around the ring of petals, from its inside out; the outer ones miss the petals.
let mut merged = None;
for radius in [110.0, 150.0, 190.0, 230.0] {
let pts: Vec<[f64; 2]> = (0..=120)
.map(|i| {
let a = std::f64::consts::TAU * f64::from(i) / 120.0;
[c[0] + radius * a.cos(), c[1] + radius * a.sin()]
})
.collect();
if let Ok(r) = s.execute("shapeBuilder.merge", &json!({"points": pts})) {
merged = r["merged"].as_u64().map(NodeId);
}
}
let (ring, rule) = outline(&node(&s, merged.expect("the drags merged")));
let inside = |p: kurbo::Point| kurbo::Shape::winding(&ring.to_bezpath(), p) != 0;
for k in 0..8 {
let a = std::f64::consts::FRAC_PI_4 * f64::from(k) - std::f64::consts::FRAC_PI_2;
let tip = kurbo::Point::new(c[0] + 165.0 * a.cos(), c[1] + 165.0 * a.sin());
assert!(inside(tip), "petal {k}'s tip at {tip:?} is in the merged shape");
}
// What is left of the petals stays out of the merged area (a piece left over it is a stray
// outline inside it).
let merged = merged.unwrap();
for id in s.doc().unwrap().selection.objects.clone().into_iter().filter(|id| *id != merged) {
let (p, r) = outline(&node(&s, id));
let over = vectorcraft_pathops::boolean(&p, r, &ring, rule, vectorcraft_pathops::BoolOp::Intersect);
let a = vectorcraft_pathops::area(&over, FillRule::NonZero);
assert!(a < 1.0, "{id:?} overlaps the merged shape by {a} pt²");
}
// No outline is a sliver or carries a spur (2 × area / length: its mean width).
for id in s.doc().unwrap().selection.objects.clone() {
let (p, r) = outline(&node(&s, id));
for sp in &p.subpaths {
let one = PathData::new(vec![sp.clone()]);
let width = 2.0 * vectorcraft_pathops::area(&one, r) / one.length();
assert!(width > 1.0, "{id:?} has an outline {width:.3} pt wide on average, {:.1} pt long", one.length());
}
}
// The ring is one outline around the centre (the regions the drags missed aside): the
// petals' tips are no outlines of their own beside it, apart by a hairline.
let big = ring.subpaths.iter().filter(|sp| vectorcraft_pathops::area(&PathData::new(vec![(*sp).clone()]), rule) > 100.0).count();
assert_eq!(big, 2, "{:?}", ring.subpaths.iter().map(|sp| vectorcraft_pathops::area(&PathData::new(vec![sp.clone()]), rule)).collect::<Vec<_>>());
// An inner edge would be a second outline over the merged area: normalising would drop it.
let clean = vectorcraft_pathops::normalize(&ring, rule);
assert!((ring.length() - clean.length()).abs() < 1.0, "{} pt of outline, {} without inner edges", ring.length(), clean.length());
}
+207
View File
@@ -0,0 +1,207 @@
//! Inline graphics in text: `text.insertInline`, editing over them, undo, following their symbol.
use serde_json::json;
use vectorcraft_doc::text::INLINE_CHAR;
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
/// A symbol `name` made of a circle `d` points across (drawn in code), its instance deleted.
fn circle_symbol(s: &mut Session, name: &str, d: f64) {
s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("symbol.new", &json!({"name": name})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
}
fn text(s: &mut Session, t: &str) -> NodeId {
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn obj(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn inline_count(t: &TextObject) -> usize {
t.runs.iter().filter(|r| r.inline.is_some()).count()
}
#[test]
fn insert_inline_places_a_symbol_as_one_character() {
let mut s = session();
circle_symbol(&mut s, "Tap", 10.0);
let id = text(&mut s, "Tap: add");
let before = obj(&s, id).bounds().unwrap();
let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1.5, "shift": 1})).unwrap();
assert_eq!(r, json!({"id": id.0, "caret": INLINE_CHAR.len_utf8()}));
let t = obj(&s, id);
assert_eq!(t.plain_text(), format!("{INLINE_CHAR}Tap: add"));
let art = t.runs[0].inline.as_ref().unwrap();
assert_eq!((art.symbol.as_str(), art.scale, art.baseline_shift), ("Tap", 1.5, 1.0));
// Resolved from the symbol (a 10 pt circle), and the text grew by the art (30 pt wide).
let b = art.bounds.unwrap();
assert!((b.width() - 10.0).abs() < 0.5 && (b.height() - 10.0).abs() < 0.5, "{b:?}");
let after = t.bounds().unwrap();
assert!((after.width() - before.width() - 30.0).abs() < 1.0, "{before:?} → {after:?}");
// It takes the style of the text it sits in.
assert_eq!(t.runs[0].style.size, 20.0);
// text.getRange reports it.
let g = s.execute("text.getRange", &json!({"id": id.0, "start": 0, "end": 3})).unwrap();
assert_eq!(g["runs"][0]["inline"], json!({"symbol": "Tap", "scale": 1.5, "baseline_shift": 1.0}));
assert_eq!(g["text"], json!(INLINE_CHAR.to_string()));
// One undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(inline_count(&obj(&s, id)), 0);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(inline_count(&obj(&s, id)), 1);
// Deleting any part of its character deletes it whole.
s.execute("text.editRange", &json!({"id": id.0, "start": 1, "end": 3})).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Tap: add");
assert_eq!(inline_count(&t), 0);
}
#[test]
fn insert_inline_rejects_junk() {
let mut s = session();
circle_symbol(&mut s, "Dot", 8.0);
let id = text(&mut s, "abc");
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
for p in [
json!({"id": id.0, "symbol": "Nope"}),
json!({"id": id.0, "symbol": 7}),
json!({"id": id.0, "symbol": "Dot", "scale": 0}),
json!({"id": id.0, "symbol": "Dot", "scale": -1}),
json!({"id": id.0, "symbol": "Dot", "scale": 1e9}),
json!({"id": id.0, "symbol": "Dot", "shift": "up"}),
json!({"id": id.0, "symbol": "Dot", "at": -3}),
json!({"id": "x", "symbol": "Dot"}),
json!({"id": rect["id"], "symbol": "Dot"}),
json!({"id": 999999, "symbol": "Dot"}),
json!({"symbol": "Dot"}),
] {
assert!(s.execute("text.insertInline", &p).is_err(), "{p}");
}
assert_eq!(inline_count(&obj(&s, id)), 0);
// Offsets past the end are clamped; mid-character offsets land on a boundary.
let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 99, "symbol": "Dot"})).unwrap();
assert_eq!(r["caret"], json!(3 + INLINE_CHAR.len_utf8()));
s.execute("text.insertInline", &json!({"id": id.0, "at": 4, "symbol": "Dot"})).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), format!("abc{INLINE_CHAR}{INLINE_CHAR}"));
assert_eq!(inline_count(&t), 2);
// A document without symbols has nothing to insert.
let mut s = session();
let id = text(&mut s, "abc");
assert!(s.execute("text.insertInline", &json!({"id": id.0})).is_err());
}
#[test]
fn type_tool_inserts_at_its_caret_and_deletes_the_graphic_whole() {
let mut s = session();
circle_symbol(&mut s, "Mana", 10.0);
s.execute("symbol.setCurrent", &json!({"name": "Mana"})).unwrap();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, 50.0, 300.0), v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Pay ", v).unwrap();
// The menu item: no params, inside a typing session.
let r = s.execute("text.insertInline", &json!({})).unwrap();
assert_eq!(r["id"], json!(id.0));
assert_eq!(s.tool_options()["caret"], json!(4 + INLINE_CHAR.len_utf8()));
s.tool_text(" now", v).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), format!("Pay {INLINE_CHAR} now"));
assert_eq!(inline_count(&t), 1);
assert_eq!(t.runs.iter().find(|r| r.inline.is_some()).unwrap().inline.as_ref().unwrap().symbol, "Mana");
// Back over " now", then the graphic: one Backspace removes it whole.
for _ in 0..5 {
s.tool_key(ToolKey::Backspace, Mods::default(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Pay ");
assert_eq!(inline_count(&t), 0);
// Undo brings it back.
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(inline_count(&obj(&s, id)), 1);
}
#[test]
fn inline_graphics_follow_their_symbol() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 1})).unwrap();
let w0 = obj(&s, id).bounds().unwrap().width();
// Redefine the symbol as a 2:1 ellipse: the graphic (20 pt tall) widens to 40 pt.
let e = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap();
s.execute("select.set", &json!({"ids": [e["id"]]})).unwrap();
s.execute("symbol.update", &json!({"name": "Dot"})).unwrap();
let t = obj(&s, id);
let b = t.runs[1].inline.as_ref().unwrap().bounds.unwrap();
assert!((b.width() / b.height() - 2.0).abs() < 0.1, "{b:?}");
let w1 = t.bounds().unwrap().width();
assert!((w1 - w0 - 20.0).abs() < 1.5, "{w0} → {w1}");
// Deleting the symbol leaves an empty slot (nothing drawn), and doesn't crash rendering.
s.execute("symbol.delete", &json!({"name": "Dot"})).unwrap();
let t = obj(&s, id);
assert!(t.runs[1].inline.as_ref().unwrap().bounds.is_none());
let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap();
let _ = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
}
#[test]
fn saved_documents_keep_inline_graphics() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 2})).unwrap();
let doc = (*s.doc().unwrap().doc).clone();
let bytes = vectorcraft_format::save(&doc, false);
assert!(String::from_utf8_lossy(&bytes).contains("\"inline\""));
let mut back = vectorcraft_format::load(&bytes).unwrap();
// Bounds aren't saved: opening resolves them.
let mut s2 = Session::new();
back.title = "copy".into();
s2.add_document(back, None);
let t = obj(&s2, id);
let art = t.runs[1].inline.as_ref().unwrap();
assert_eq!((art.symbol.as_str(), art.scale), ("Dot", 2.0));
assert!(art.bounds.is_some());
assert_eq!(t.cached_bounds, obj(&s, id).cached_bounds);
}
#[test]
fn create_outlines_turns_inline_graphics_into_symbol_instances() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "a b");
s.execute("text.insertInline", &json!({"id": id.0, "at": 2, "symbol": "Dot"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let r = s.execute("type.createOutlines", &json!({})).unwrap();
let g = NodeId(r["ids"][0].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
let kids = d.node(g).unwrap().children().unwrap();
let inst = kids.iter().filter(|c| matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Dot")).count();
assert_eq!(inst, 1);
// Between the outlines of `a` and `b`.
let pos = kids.iter().position(|c| matches!(c.kind, NodeKind::SymbolInstance { .. })).unwrap();
assert!(pos > 0 && pos + 1 < kids.len(), "{pos} of {}", kids.len());
}
+2 -1
View File
@@ -320,7 +320,8 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() {
let doc_path = dir.file("part.vectorcraft");
save(&mut s, &doc_path);
let mut s = session();
s.execute("file.place", &json!({"path": doc_path})).unwrap();
// As an editable copy (placed linked, it stays one locked object).
s.execute("file.place", &json!({"path": doc_path, "link": false})).unwrap();
let doc = &s.doc().unwrap().doc;
let mut placed = vec![];
doc.visit_images(|_, im| placed.push(im.clone()));
+10
View File
@@ -248,6 +248,16 @@ fn text_set_format() {
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
assert_eq!(t.first_style().kerning, None);
assert!(s.execute("text.setFormat", &json!({"ids": [id.0]})).is_err());
// Paragraph composer: Every-line by default, undoable.
assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine);
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap();
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
assert_eq!(t.para.composer, vectorcraft_doc::Composer::SingleLine);
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "best"})).is_err());
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": 1})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine);
}
#[test]
+207
View File
@@ -0,0 +1,207 @@
//! Paragraph attributes per paragraph: ranged Paragraph panel commands, paragraph styles on one
//! paragraph, splitting and merging paragraphs while editing, undo and threaded text.
use serde_json::json;
use vectorcraft_doc::{Justify, NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn area(s: &mut Session, text: &str) -> u64 {
s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 300}})).unwrap()["id"].as_u64().unwrap()
}
fn text(s: &Session, id: u64) -> TextObject {
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn justs(s: &Session, id: u64) -> Vec<Justify> {
text(s, id).paragraph_styles().iter().map(|p| p.justify).collect()
}
#[test]
fn ranged_paragraph_commands_touch_only_their_paragraphs() {
let mut s = session();
let id = area(&mut s, "one\ntwo\nthree");
// A caret in "two" (byte 5): only the second paragraph.
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// A selection from "one" into "two": both.
s.execute("text.setFormat", &json!({"ids": [id], "spaceBefore": 9, "leftIndent": 4, "start": 1, "end": 5})).unwrap();
let t = text(&s, id);
let sb: Vec<f64> = t.paragraph_styles().iter().map(|p| p.space_before).collect();
assert_eq!(sb, [9.0, 9.0, 0.0]);
assert_eq!(t.para.left_indent, 4.0, "paragraph 0 is `para`");
// Character attributes of setFormat follow the range too.
s.execute("text.setFormat", &json!({"ids": [id], "underline": true, "start": 4, "end": 7})).unwrap();
let t = text(&s, id);
assert!(t.runs.iter().any(|r| r.text == "two" && r.style.underline));
assert!(t.runs.iter().any(|r| r.text.starts_with("one") && !r.style.underline));
// The layout sees each paragraph's alignment.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.lines[1].x0 > lay.lines[0].x0 + 20.0, "the centred line sits right of the left one");
// No range: every paragraph (as before).
s.execute("text.setStyle", &json!({"id": id, "justify": "right"})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right; 3]);
// Undo steps back through the ranged edits.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// Tabs per paragraph; a query reads the paragraph at `start`.
s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 40}], "start": 9, "end": 9})).unwrap();
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id], "start": 9})).unwrap()["stops"].as_array().unwrap().len(), 1);
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id]})).unwrap()["stops"], json!([]));
// Junk ranges are errors, not panics.
assert!(s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": -3})).is_err());
assert!(s.execute("text.setStyle", &json!({"id": id, "justify": "left", "end": "x"})).is_err());
s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": 1_000_000, "end": u64::MAX})).unwrap();
assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph");
}
#[test]
fn editing_splits_and_merges_paragraph_styles() {
let mut s = session();
let id = area(&mut s, "one\ntwo\nthree");
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
// A break inside "two": both halves keep its style.
s.execute("text.editRange", &json!({"id": id, "start": 5, "end": 5, "insert": "\n"})).unwrap();
assert_eq!(text(&s, id).plain_text(), "one\nt\nwo\nthree");
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
// Deleting the break after "one": the first paragraph's style wins.
s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 4})).unwrap();
assert_eq!(text(&s, id).plain_text(), "onet\nwo\nthree");
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// Pasting styled runs with breaks in "wo": the new paragraphs continue it.
let runs = json!([{"text": "A\nB\n", "style": text(&s, id).runs[0].style}]);
s.execute("text.editRange", &json!({"id": id, "start": 6, "end": 6, "runs": runs})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Center, Justify::Auto]);
// Undo restores the styles with the text.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
// Replacing all the text: every paragraph takes the first one's attributes.
s.execute("text.setText", &json!({"ids": [id], "text": "x\ny"})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Auto]);
assert!(text(&s, id).paras.is_empty());
}
#[test]
fn return_in_the_type_tool_continues_the_paragraph_it_splits() {
let mut s = session();
let v = ViewInfo::default();
let id = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "one\ntwo", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setStyle", &json!({"id": id, "justify": "right", "start": 0, "end": 0})).unwrap();
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
s.select_tool("type", v).unwrap();
// Click into the first line (right-aligned: it ends at the origin), then End and Return.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
let l0 = &lay.lines[0];
let (x, y) = (100.0 + (l0.x0 + l0.x1) / 2.0, 100.0 + l0.baseline - 5.0);
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
s.tool_key(ToolKey::End, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Enter, Mods::default(), v).unwrap();
s.tool_text("new", v).unwrap();
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(text(&s, id).plain_text(), "one\nnew\ntwo");
assert_eq!(justs(&s, id), [Justify::Right, Justify::Right, Justify::Center]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]);
}
#[test]
fn paragraph_style_applies_to_one_paragraph() {
let mut s = session();
let id = area(&mut s, "Title\nBody text");
// The second paragraph keeps new type's attributes, whatever they are.
let body = text(&s, id).para_at(1).clone();
s.execute("paraStyle.new", &json!({"name": "Head", "attrs": {"justify": "Center", "space_after": 8.0}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Head", "id": id, "start": 2, "end": 2})).unwrap();
let t = text(&s, id);
assert_eq!(t.para_at(0).style_name.as_deref(), Some("Head"));
assert_eq!((t.para_at(0).justify, t.para_at(0).space_after), (Justify::Center, 8.0));
assert_eq!(t.para_at(1), &body, "new type's");
let list = s.execute("paraStyle.list", &json!({})).unwrap();
assert_eq!(list["styles"][1]["uses"], 1, "one paragraph uses it");
assert_eq!(list["styles"][0]["uses"], 1, "the other is Normal");
// Redefining updates that paragraph only; renaming follows it.
s.execute("paraStyle.setAttrs", &json!({"name": "Head", "attrs": {"justify": "Right"}})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right, Justify::Auto]);
s.execute("paraStyle.rename", &json!({"name": "Head", "to": "Heading"})).unwrap();
assert_eq!(text(&s, id).para_at(0).style_name.as_deref(), Some("Heading"));
// New from the selection reads the caret's paragraph.
s.execute("paraStyle.new", &json!({"name": "From body", "id": id, "start": 8})).unwrap();
let defs = &s.doc().unwrap().doc.para_styles;
assert_eq!(defs.iter().find(|d| d.name == "From body").unwrap().attrs["justify"], "Auto");
}
#[test]
fn threaded_frames_keep_each_paragraphs_style() {
let mut s = session();
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(3) + "\nSecond paragraph.\nThird.";
let a =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
let joined = |s: &Session| format!("{}{}", text(s, a).plain_text(), text(s, b).plain_text());
assert_eq!(joined(&s), story);
// Centre the second paragraph (in frame b) and right-align the third.
let tb = text(&s, b).plain_text();
let second = tb.find("Second").unwrap();
s.execute("text.setStyle", &json!({"id": b, "justify": "center", "start": second, "end": second})).unwrap();
let third = text(&s, b).plain_text().find("Third").unwrap();
s.execute("text.setStyle", &json!({"id": b, "justify": "right", "start": third, "end": third})).unwrap();
assert_eq!(justs(&s, b), [Justify::Auto, Justify::Center, Justify::Right]);
// Typing in the first frame re-flows the story: the styles move with their paragraphs.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "More words at the start of the story. "})).unwrap();
let (ja, jb) = (justs(&s, a), justs(&s, b));
assert_eq!(ja, [Justify::Auto]);
assert_eq!(jb, [Justify::Auto, Justify::Center, Justify::Right]);
// A break in the first frame makes a new paragraph there; the later ones keep their styles.
s.execute("text.editRange", &json!({"id": a, "start": 4, "end": 4, "insert": "\n"})).unwrap();
let all: Vec<Justify> = [justs(&s, a), justs(&s, b)].concat();
assert_eq!(all.iter().filter(|j| **j == Justify::Center).count(), 1);
assert_eq!(all.last(), Some(&Justify::Right));
// Restyling the paragraph that continues into frame b (its first) sticks after the re-flow.
s.execute("text.setStyle", &json!({"id": b, "justify": "justifyAll", "start": 0, "end": 0})).unwrap();
assert_eq!(justs(&s, b)[0], Justify::JustifyAll);
assert_eq!(*justs(&s, a).last().unwrap(), Justify::JustifyAll, "the paragraph's part in frame a follows");
}
/// Upstream's paragraph attributes (direction, mojikumi, leading model) follow a range like the
/// others: only the touched paragraphs change, and the layout reads each paragraph's own.
#[test]
fn ranged_set_format_sets_direction_mojikumi_and_leading_model_per_paragraph() {
use vectorcraft_doc::{LeadingModel, Mojikumi, ParaDirection};
let mut s = session();
let id = area(&mut s, "Hello.\nHello.");
// A caret in the second paragraph (byte 9).
s.execute(
"text.setFormat",
&json!({"ids": [id], "direction": "rightToLeft", "leadingModel": "emBoxTop", "mojikumi": "none", "start": 9, "end": 9}),
)
.unwrap();
let t = text(&s, id);
let (p0, p1) = (t.para_at(0).clone(), t.para_at(1).clone());
assert_eq!((p0.direction, p0.leading_model, p0.mojikumi), (None, LeadingModel::RomanBaseline, Mojikumi::LineEndHalf));
assert_eq!((p1.direction, p1.leading_model, p1.mojikumi), (Some(ParaDirection::RightToLeft), LeadingModel::EmBoxTop, Mojikumi::None));
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(!lay.lines[0].rtl && lay.lines[1].rtl, "each paragraph's direction");
// Return in the right-to-left paragraph continues it.
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nX"})).unwrap();
let t = text(&s, id);
assert_eq!(t.para_at(2).direction, Some(ParaDirection::RightToLeft));
// No range: every paragraph.
s.execute("text.setFormat", &json!({"ids": [id], "direction": "auto"})).unwrap();
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.direction.is_none()));
}
+481
View File
@@ -0,0 +1,481 @@
//! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read
//! from a file), drawn and output as vectors, saved with its preview, checked, updated and
//! relinked when the file changes, and turned into an editable copy (Break Link, Expand).
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PlacedDocument};
use vectorcraft_geom::{Rect, shapes};
use super::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write};
use super::*;
/// A VectorCraft document at `path`: one `w`×`h` artboard filled with `rgb` (and `extra` more
/// rectangles, which change the file's size).
fn source(path: &str, w: f64, h: f64, rgb: [u8; 3], extra: usize) {
let mut d = Document::new(w, h);
let layer = d.layers[0].id;
let [r, g, b] = rgb;
for i in 0..=extra {
let id = d.alloc_id();
let look = Appearance::basic(Paint::solid(Color::rgb8(r, g, b)), Paint::None, 0.0);
d.insert(Some(layer), i, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, w, h)), look)).unwrap();
}
write(path, &vectorcraft_format::save_file(&d));
}
/// A document drawing symbol `Star` (a blue 100×50 rectangle) at `path`, titled `title` (tests
/// that remove the file give it content of its own: files read are cached by content).
fn star_source(path: &str, title: &str) {
let mut d = Document::new(100.0, 50.0);
d.title = title.into();
let layer = d.layers[0].id;
let art = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look(BLUE));
d.symbols.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(art) });
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
write(path, &vectorcraft_format::save_file(&d));
}
fn look(c: [u8; 3]) -> Appearance {
Appearance::basic(Paint::solid(Color::rgb8(c[0], c[1], c[2])), Paint::None, 0.0)
}
/// The active document gets a red symbol named `Star` of its own.
fn parent_star(s: &mut Session) {
let star = Node::path(vectorcraft_doc::NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(RED));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc)
.symbols
.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(star) });
}
/// Place `path` (linked: the default), centred on the artboard → its id.
fn place(s: &mut Session, path: &str) -> NodeId {
let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
NodeId(r["ids"][0].as_u64().unwrap())
}
fn placed(s: &Session, id: NodeId) -> PlacedDocument {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::PlacedDocument(p) => (**p).clone(),
k => panic!("not a placed document: {k:?}"),
}
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn decode(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
/// Images and paths in `bytes`, a PDF, read back.
fn pdf_contents(bytes: &[u8]) -> (usize, usize) {
// The page as drawn (not the VectorCraft document the PDF carries).
let (doc, _) = super::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap();
let (mut images, mut paths) = (0, 0);
doc.walk(|n| match n.kind {
NodeKind::Image(_) => images += 1,
NodeKind::Path { .. } | NodeKind::Compound { .. } => paths += 1,
_ => {}
});
(images, paths)
}
/// The JSON of a `.vectorcraft` file (the data after it left out).
fn file_json(bytes: &[u8]) -> Value {
let end = bytes.windows(vectorcraft_format::BLOB_MAGIC.len()).position(|w| w == vectorcraft_format::BLOB_MAGIC).unwrap_or(bytes.len());
serde_json::from_slice(&bytes[..end]).unwrap()
}
#[test]
fn a_document_places_linked_by_default_at_its_artboard_size() {
let dir = Folder::new("placed-basic");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
let p = placed(&s, id);
assert!((p.width - 100.0).abs() < 1e-6 && (p.height - 50.0).abs() < 1e-6, "{p:?}");
assert_eq!(bounds(&s, id), Rect::new(150.0, 125.0, 250.0, 175.0));
assert_eq!((p.link.path.as_str(), p.link.page), (src.as_str(), Some(1)));
let n = s.doc().unwrap().doc.node(id).unwrap();
assert_eq!((n.name.as_deref(), n.kind_label()), (Some("logo.vectorcraft"), "Placed Document"));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
// It moves and scales like any object.
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(bounds(&s, id), Rect::new(160.0, 125.0, 260.0, 175.0));
// Its art's bounds instead of the artboard.
let mut d = Document::new(100.0, 50.0);
let layer = d.layers[0].id;
let id2 = d.alloc_id();
d.insert(Some(layer), 0, Node::path(id2, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 30.0)), look(RED))).unwrap();
let small = dir.file("small.vectorcraft");
write(&small, &vectorcraft_format::save_file(&d));
let r = s.execute("file.place", &json!({"path": small, "crop": "bounding", "at": [200, 150]})).unwrap();
let p = placed(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert!(p.bounding && (p.width - 30.0).abs() < 1e-6 && (p.height - 20.0).abs() < 1e-6, "{p:?}");
}
#[test]
fn without_link_or_a_file_a_document_places_as_an_editable_copy() {
let dir = Folder::new("placed-copy");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let data = vectorcraft_format::base64_encode(&std::fs::read(&src).unwrap());
for p in [json!({"path": src, "link": false}), json!({"name": "logo.vectorcraft", "dataBase64": data})] {
let r = s.execute("file.place", &p).unwrap();
assert_eq!(r["linked"], false, "{r}");
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
assert_eq!(n.kind_label(), "Clip Group", "{p}");
}
assert!(!s.doc().unwrap().doc.has_placed());
}
#[test]
fn a_placed_document_is_vectors_in_every_output() {
let dir = Folder::new("placed-outputs");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
place(&mut s, &src);
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let text = svg["text"].as_str().unwrap();
assert!(!text.contains("<image") && (text.contains("<path") || text.contains("<rect")), "drawn as paths: {text}");
let (images, paths) = pdf_contents(&decode(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap()));
assert_eq!(images, 0, "no image in the PDF");
assert!(paths > 0);
let png = decode(&s.execute("document.serialize", &json!({"format": "png"})).unwrap());
let [r, g, b, _] = image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(200, 150).0;
assert!(near([r, g, b], RED), "{:?}", [r, g, b]);
for format in ["eps", "emf", "dxf"] {
let out = s.execute("document.serialize", &json!({"format": format})).unwrap();
assert!(out.to_string().len() > 200, "{format}: {out}");
}
let info = s.execute("document.info", &json!({})).unwrap();
assert!(info.to_string().contains("\"placedDocuments\""), "{info}");
}
#[test]
fn the_preview_is_saved_so_the_document_shows_it_without_its_file() {
let dir = Folder::new("placed-saved");
let src = dir.file("logo.vectorcraft");
// Content of its own: files read are cached by content, and this one is removed.
source(&src, 100.0, 50.0, RED, 2);
let mut s = session();
let id = place(&mut s, &src);
let doc_path = dir.file("poster/poster.vectorcraft");
std::fs::create_dir_all(dir.file("poster")).unwrap();
save(&mut s, &doc_path);
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
let kind = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"];
assert_eq!(kind["type"], "placeddocument");
assert_eq!(kind["link"]["relative"], "../logo.vectorcraft");
let key = kind["key"].as_str().unwrap();
let entry = &saved["images"][key];
assert_eq!((entry["proxy"].as_bool(), entry["mime"].as_str()), (Some(true), Some("image/jpeg")), "the preview, as JPEG (opaque)");
// Include Linked Files keeps the file itself.
let full = file_json(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "includeLinked": true})).unwrap()));
assert_eq!((full["images"][key]["proxy"].clone(), full["images"][key]["mime"].as_str()), (Value::Null, Some("application/json")));
std::fs::remove_file(&src).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!(r["missingLinks"].as_array().map(Vec::len), Some(1), "{r}");
assert_eq!(placed(&s, id).link.path, src);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "shown from the saved preview");
assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 1);
// Output with the file gone: the preview, with a warning.
let png = s.execute("document.serialize", &json!({"format": "png"})).unwrap();
assert!(png["warnings"].to_string().contains("output as their previews"), "{png}");
let [r, g, b, _] = image::load_from_memory(&decode(&png)).unwrap().to_rgba8().get_pixel(200, 150).0;
assert!(near([r, g, b], RED), "{:?}", [r, g, b]);
// Saved for older apps: the art it shows (here its preview), as plain objects.
let old: Value =
serde_json::from_slice(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": 2})).unwrap())).unwrap();
let old = old["document"]["layers"][0]["kind"]["children"][0].to_string();
assert!(old.contains("\"image\"") && !old.contains("placeddocument"), "{old}");
}
#[test]
fn a_document_saved_with_previews_outputs_its_files_art() {
let dir = Folder::new("placed-preview-output");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
open(&mut s, &doc_path);
let key = placed(&s, id).key;
assert!(s.doc().unwrap().doc.images[&key].is_proxy(), "opened with the preview only");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
// Output reads the file again: vectors, no warning.
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
assert_eq!(svg["warnings"], json!([]), "{}", svg["warnings"]);
let text = svg["text"].as_str().unwrap();
assert!(!text.contains("<image") && (text.contains("<path") || text.contains("<rect")), "vectors");
// So does saving with Include Linked Files, and the document stays as it was.
let full = file_json(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "includeLinked": true})).unwrap()));
assert_eq!(full["images"][&key]["mime"], "application/json");
assert!(s.doc().unwrap().doc.images[&key].is_proxy());
}
#[test]
fn a_changed_file_is_modified_and_update_links_reads_it_again() {
let dir = Folder::new("placed-update");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let before = bounds(&s, id);
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!((c["modified"].as_u64(), c["links"][0]["status"].as_str()), (Some(0), Some("ok")), "{c}");
let list = s.execute("links.list", &json!({})).unwrap();
let row = &list["links"][0];
assert_eq!((row["format"].as_str(), row["document"].as_bool(), row["status"].as_str()), (Some("VectorCraft"), Some(true), Some("ok")), "{row}");
// A blue document, wider, and a different file size.
source(&src, 200.0, 50.0, BLUE, 1);
assert_eq!(s.execute("links.check", &json!({})).unwrap()["links"][0]["status"], "modified");
// Until updated, it shows (and outputs) the file as it read it.
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
assert_eq!(svg["warnings"], json!([]));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u["updated"], json!([id.0]), "{u}");
assert_eq!(placed(&s, id).width, 200.0);
assert_eq!(bounds(&s, id), before, "keeps its bounds");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0);
// One undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn update_links_on_open_reads_a_modified_file_again() {
let dir = Folder::new("placed-open");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
place(&mut s, &src);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
source(&src, 100.0, 50.0, BLUE, 1);
// Ask When Modified (the default): reported, left as it was.
let r = open(&mut s, &doc_path);
assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
// Automatically: read again as the document opens.
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!(r["updatedLinks"].as_array().map(Vec::len), Some(1), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
#[test]
fn relink_points_a_placed_document_at_another_file() {
let dir = Folder::new("placed-relink");
let (a, b) = (dir.file("a.vectorcraft"), dir.file("b.vectorcraft"));
source(&a, 100.0, 50.0, RED, 0);
source(&b, 100.0, 50.0, BLUE, 0);
let mut s = session();
let id = place(&mut s, &a);
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": b})).unwrap();
assert_eq!(r["relinked"], json!([id.0]), "{r}");
assert_eq!(placed(&s, id).link.path, b);
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
#[test]
fn a_placed_document_inside_a_placed_document_outputs_from_its_own_file() {
let dir = Folder::new("placed-nested");
let (logo, card) = (dir.file("logo.vectorcraft"), dir.file("card.vectorcraft"));
source(&logo, 100.0, 50.0, RED, 0);
// The card places the logo, and is saved with the logo's preview only.
let mut s = session();
place(&mut s, &logo);
save(&mut s, &card);
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
place(&mut s, &card);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
// The logo inside is read from its file: vectors, not its preview.
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let text = svg["text"].as_str().unwrap();
assert!(!text.contains("<image"), "the logo's file, not its preview: {text}");
assert_eq!(pdf_contents(&decode(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap())).0, 0);
}
#[test]
fn documents_that_place_each_other_dont_loop() {
let dir = Folder::new("placed-loop");
let (a, b) = (dir.file("a.vectorcraft"), dir.file("b.vectorcraft"));
source(&a, 100.0, 50.0, RED, 0);
// B places A; A then places B.
let mut s = session();
place(&mut s, &a);
save(&mut s, &b);
s.execute("document.open", &json!({"path": a})).unwrap();
place(&mut s, &b);
save(&mut s, &a);
s.execute("document.open", &json!({"path": b})).unwrap();
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u["updated"].as_array().map(Vec::len), Some(1), "{u}");
// Drawn and output a few levels deep, then their previews.
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
for format in ["svg", "pdf", "png"] {
s.execute("document.serialize", &json!({"format": format})).unwrap();
}
}
#[test]
fn what_cant_be_placed_linked_is_an_error() {
let dir = Folder::new("placed-errors");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let place = |s: &mut Session, p: Value| s.execute("file.place", &p).unwrap_err().to_string();
let e = place(&mut s, json!({"path": src, "page": 2}));
assert!(e.contains("page 2") && e.contains("1 artboard"), "{e}");
let e = place(&mut s, json!({"path": src, "crop": "art"}));
assert!(e.contains("crop"), "{e}");
let empty = dir.file("empty.vectorcraft");
write(&empty, &vectorcraft_format::save_file(&Document::new(100.0, 50.0)));
let e = place(&mut s, json!({"path": empty, "crop": "bounding"}));
assert!(e.contains("no art to place"), "{e}");
// Junk params are errors, not crashes.
for v in [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([1, 2]), json!({"a": {"b": []}})] {
for k in ["link", "page", "crop", "at", "rect"] {
let _ = s.execute("file.place", &json!({"path": src, k: v}));
}
}
}
#[test]
fn a_placed_documents_resources_stay_its_own() {
let dir = Folder::new("placed-symbols");
let src = dir.file("badge.vectorcraft");
star_source(&src, "star");
let mut s = session();
parent_star(&mut s);
place(&mut s, &src);
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE), "the file's own Star");
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.symbols.len(), 1, "nothing joins the document");
let prefix = vectorcraft_doc::placed_document::RESOURCE_PREFIX;
assert!(doc.images.keys().all(|k| !k.starts_with(prefix)));
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
assert!(!svg["text"].as_str().unwrap().contains("<image"));
// Nor its saves.
let saved = String::from_utf8(decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap())).unwrap();
assert!(!saved.contains(prefix), "{saved}");
// Saved for older apps: the art and its resources, as plain objects.
let old = vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": 2})).unwrap())).unwrap();
assert!(!old.has_placed());
assert!(near(centre_colour(&old), BLUE));
}
#[test]
fn break_link_gives_the_editable_copy_placing_without_link_gives() {
let dir = Folder::new("placed-break");
let src = dir.file("badge.vectorcraft");
star_source(&src, "break");
let mut s = session();
parent_star(&mut s);
// The copy placing without Link gives.
let r = s.execute("file.place", &json!({"path": src, "link": false, "at": [200, 150]})).unwrap();
let copy = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
s.execute("edit.undo", &json!({})).unwrap();
let id = place(&mut s, &src);
let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["embedded"], json!([id.0]), "{r}");
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
assert!(!doc.has_placed() && n.kind_label() == "Clip Group", "{}", n.kind_label());
assert_eq!((n.name.as_deref(), n.geometric_bounds()), (copy.name.as_deref(), copy.geometric_bounds()));
let kinds = |n: &Node| {
let mut k = vec![];
n.walk(&mut |c| k.push(c.kind_label()));
k
};
assert_eq!(kinds(n), kinds(&copy));
assert!(near(centre_colour(doc), BLUE), "the art as it showed");
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names.len(), 2, "the file's Star joined, renamed: {names:?}");
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"], json!([]));
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Embed");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.has_placed());
// A file that can't be read stays linked.
std::fs::remove_file(&src).unwrap();
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
open(&mut s, &doc_path);
let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap();
assert_eq!((r["embedded"].clone(), r["missing"].clone()), (json!([]), json!([id.0])), "{r}");
}
#[test]
fn expand_turns_a_placed_document_into_editable_art_with_its_resources() {
let dir = Folder::new("placed-expand");
let src = dir.file("badge.vectorcraft");
star_source(&src, "star");
let mut s = session();
parent_star(&mut s);
let id = place(&mut s, &src);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let before = bounds(&s, id);
s.execute("object.expand", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
assert!(n.is_container() && !doc.has_placed(), "{:?}", n.kind_label());
let after = n.geometric_bounds().unwrap();
assert!((after.width() - before.width()).abs() < 0.5 && (after.height() - before.height()).abs() < 0.5, "{after:?} vs {before:?}");
assert!(near(centre_colour(doc), BLUE));
let saved = vectorcraft_format::save_file(doc);
assert!(near(centre_colour(&vectorcraft_format::load(&saved).unwrap()), BLUE), "saved with its resources");
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!(info["object"], false, "nothing left to expand: {info}");
}
#[test]
fn saving_a_document_refreshes_the_open_documents_that_place_it() {
let dir = Folder::new("placed-refresh");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
let parent = s.active_index().unwrap();
// Open the file, recolour it and save it.
s.execute("document.open", &json!({"path": src})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#1414e6"})).unwrap();
s.execute("document.save", &json!({})).unwrap();
let child = s.active_index().unwrap();
assert_ne!(parent, child);
let doc = &s.documents()[parent];
assert_eq!(doc.history.undo.last().unwrap().label, "Update Links");
assert!(near(centre_colour(&doc.doc), BLUE), "shows the saved version");
assert!(matches!(doc.doc.node(id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_))));
assert_eq!(s.active_index(), Some(child), "the saved document stays active");
}
#[test]
fn link_info_gives_the_artboards_size_not_pixels() {
let dir = Folder::new("placed-info");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let i = s.execute("links.info", &json!({"id": id.0})).unwrap();
assert_eq!((i["pageWidth"].as_f64(), i["pageHeight"].as_f64(), i["document"].as_bool()), (Some(100.0), Some(50.0), Some(true)), "{i}");
assert!(i.get("pixelWidth").is_none() && i.get("ppi").is_none(), "no pixels: {i}");
assert_eq!(
(i["status"].as_str(), i["fileName"].as_str(), i["scale"].clone()),
(Some("ok"), Some("logo.vectorcraft"), json!([200.0, 200.0])),
"{i}"
);
}
+31
View File
@@ -260,6 +260,37 @@ fn the_saved_view_round_trips_without_dirtying() {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn open_layers_rows_round_trip_without_dirtying() {
let d = dir("open-rows");
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
// A document starts with its top-level layers open, and a file keeps nothing for that.
assert!(s.doc().unwrap().layers_open.contains(layer) && !s.doc().unwrap().layers_open.contains(g));
let native = path(&d, "rows.vectorcraft");
s.execute("document.save", &json!({"path": native})).unwrap();
let bytes = std::fs::read(&native).unwrap();
assert_eq!(vectorcraft_format::load(&bytes).unwrap().layers_open, None);
// Opening the group and closing the layer is view state: not an edit, kept by the file.
let st = s.doc_mut().unwrap();
st.layers_open.set(g, true);
st.layers_open.set(layer, false);
assert!(!s.doc().unwrap().is_dirty());
s.execute("document.save", &json!({"path": native})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers_open, None, "only the written file carries it");
let mut again = session();
again.execute("document.open", &json!({"path": native})).unwrap();
let open = &again.doc().unwrap().layers_open;
assert!(open.contains(g) && !open.contains(layer));
// Older files (none saved) open with the top-level layers open.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap();
assert_eq!(doc.layers_open, None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn save_plans_suggest_names_and_folders() {
let mut s = session();
+347
View File
@@ -0,0 +1,347 @@
use super::*;
use serde_json::json;
use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX};
use vectorcraft_doc::{LiveShape, Node, NodeKind};
use vectorcraft_geom::{Point, Shape as _};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
s
}
fn rectangle(s: &mut Session, x: f64, fill: &str) -> NodeId {
s.execute("paint.setFill", &json!({"color": fill, "ids": []})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap();
NodeId(id)
}
fn scribble(s: &mut Session, pts: &[[f64; 2]]) -> NodeId {
NodeId(s.execute("shaper.scribble", &json!({"points":pts})).unwrap()["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> &Node {
s.doc().unwrap().doc.node(id).unwrap()
}
fn fill(s: &Session, id: NodeId, x: f64, y: f64) -> Option<String> {
node(s, id)
.children()
.unwrap()
.iter()
.filter(|n| n.name.as_deref() != Some(SOURCES))
.rev()
.find(|n| {
n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX))
&& n.path_data().is_some_and(|p| p.to_bezpath().winding(Point::new(x, y)) != 0)
})
.map(|n| n.appearance.fill_paint().label())
}
fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
node(s, id).children().unwrap().iter().filter(|n| n.name.as_deref() != Some(SOURCES)).any(|n| {
n.name.as_deref().is_some_and(|n| n.starts_with(STROKE_PREFIX))
&& !n.appearance.stroke_paint().is_none()
&& n.path_data().and_then(|p| p.nearest(Point::new(x, y))).is_some_and(|(_, _, _, _, d)| d < 0.1)
})
}
#[test]
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
for reverse in [false, true] {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let originals = [node(&s, a).clone(), node(&s, b).clone()];
let undo = s.doc().unwrap().history.undo.len();
let mut pts = vec![[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0], [75.0, 80.0], [125.0, 85.0]];
if reverse {
pts.reverse();
}
let g = scribble(&mut s, &pts);
let color = if reverse { "#0000ff" } else { "#ff0000" };
for x in [25.0, 75.0, 125.0] {
assert_eq!(fill(&s, g, x, 50.0).as_deref(), Some(color));
}
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
assert_eq!(node(&s, g).children().unwrap()[0].name.as_deref(), Some(SOURCES));
assert!(!stroke(&s, g, 50.0, 50.0));
assert!(stroke(&s, g, 0.0, 50.0));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some(color));
}
}
#[test]
fn shaper_interior_punch_removes_the_whole_visible_front_face() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let originals = [node(&s, a).clone(), node(&s, b).clone()];
let g = scribble(&mut s, &[[65.0, 20.0], [85.0, 40.0], [65.0, 60.0], [85.0, 80.0]]);
assert_eq!(fill(&s, g, 75.0, 50.0), None);
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
assert_eq!(fill(&s, g, 125.0, 50.0), None);
assert!(stroke(&s, g, 50.0, 50.0) && stroke(&s, g, 150.0, 50.0));
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
}
#[test]
fn shaper_exiting_scribble_erases_only_touched_fill_and_boundary_strokes() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]);
assert_eq!(fill(&s, g, 125.0, 50.0), None);
assert!(!stroke(&s, g, 150.0, 50.0));
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
assert_eq!(fill(&s, g, 75.0, 50.0), None);
assert!(stroke(&s, g, 0.0, 50.0));
}
#[test]
fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() {
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
let circle = NodeId(s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap());
let line = NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
let original_line = node(&s, line).clone();
let g = scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]);
assert!(!stroke(&s, g, 75.0, 150.0));
assert!(stroke(&s, g, 150.0, 150.0));
assert!(stroke(&s, g, 225.0, 150.0));
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
assert_eq!(node(&s, line), &original_line);
assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. }));
let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]);
assert!(!stroke(&s, g, 225.0, 150.0));
assert!(stroke(&s, g, 150.0, 150.0));
assert_eq!(node(&s, line), &original_line);
}
#[test]
fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = 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]]);
s.execute("object.move", &json!({"ids":[a.0],"dx":20,"dy":10})).unwrap();
assert_eq!(fill(&s, g, 30.0, 15.0).as_deref(), Some("#ff0000"));
assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0);
s.execute("select.set", &json!({"ids":[a.0]})).unwrap();
s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap();
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. }));
let doc = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
assert_eq!(back.layers, doc.layers);
let originals = [node(&s, a).clone(), node(&s, b).clone()];
s.execute("shaper.release", &json!({"id":g.0})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
}
#[test]
fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() {
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]]);
s.execute("shaper.select", &json!({"point":[75,50]})).unwrap();
s.execute("paint.setFill", &json!({"color":"#00ff00"})).unwrap();
s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap();
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
s.execute("shaper.expand", &json!({"id":g.0})).unwrap();
assert!(node(&s, g).shaper.is_none());
assert!(s.doc().unwrap().doc.node(a).is_none());
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
}
#[test]
fn shaper_double_click_selects_an_original_for_construction_editing() {
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 v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
let requests = s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 25.0, 50.0), v).unwrap();
assert!(requests.iter().any(|r| matches!(r, UiRequest::SwitchTool(tool) if tool == "selection")));
s.select_tool("selection", v).unwrap();
assert_eq!(s.tool_id(), "selection");
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().isolation, Some(node(&s, g).children().unwrap()[0].id));
assert!(s.doc().unwrap().doc.is_editable(a));
s.execute("object.move", &json!({"dx":10,"dy":0})).unwrap();
assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000"));
s.execute("object.exitIsolation", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
}
#[test]
fn shaper_lines_keep_free_angles_with_or_without_shift() {
for shift in [false, true] {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
for (kind, x, y) in [(PointerKind::Down, 30.0, 40.0), (PointerKind::Drag, 130.0, 70.0), (PointerKind::Up, 230.0, 100.0)] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap();
}
let n = node(&s, s.doc().unwrap().selection.objects[0]);
assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0)));
}
}
#[test]
fn shaper_rejects_oversized_or_invalid_gestures_without_changing_art() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
for params in [json!({"points":vec![[1.0,1.0];4097]}), json!({"points":[[1,1],[2,2]],"tolerance":0}), json!({"points":[[1e100,0]]})] {
assert!(s.execute("shaper.scribble", &params).is_err());
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
}
#[test]
fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() {
use vectorcraft_doc::hit::{HitOptions, hit_test};
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();
let rotate = |x: f64, y: f64| {
let (x, y) = (x - 150.0, y - 150.0);
Point::new(150.0 + (x + y) / 2.0f64.sqrt(), 150.0 + (y - x) / 2.0f64.sqrt())
};
let (a, b) = (rotate(50.0, 150.0), rotate(250.0, 150.0));
let line = NodeId(s.execute("shape.line", &json!({"x1":a.x,"y1":a.y,"x2":b.x,"y2":b.y})).unwrap()["id"].as_u64().unwrap());
let original = node(&s, line).clone();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
for offset in [0.0, 150.0] {
let points = [(60.0, 140.0), (70.0, 160.0), (80.0, 140.0), (90.0, 160.0)];
for (i, (x, y)) in points.into_iter().enumerate() {
let p = rotate(x + offset, y);
let kind = if i == 0 {
PointerKind::Down
} else if i == 3 {
PointerKind::Up
} else {
PointerKind::Drag
};
s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap();
}
}
let g = s.doc().unwrap().selection.objects[0];
assert!(node(&s, g).shaper.is_some());
assert_eq!(node(&s, line), &original);
assert!(stroke(&s, g, 150.0, 150.0));
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
let sources = node(&s, g).children().unwrap()[0].id;
let doc = &s.doc().unwrap().doc;
for x in [75.0, 225.0] {
let p = rotate(x, 150.0);
assert!(!stroke(&s, g, p.x, p.y));
assert!(!node(&s, g).geometric_bounds().unwrap().contains(p));
assert!(!node(&s, g).visual_bounds().unwrap().contains(p));
assert!(hit_test(doc, p, HitOptions { tol: 0.5, ..Default::default() }).is_none());
assert_eq!(hit_test(doc, p, HitOptions { tol: 0.5, scope: Some(sources), ..Default::default() }).unwrap().leaf, line);
}
let p = rotate(75.0, 150.0);
s.execute("shaper.select", &json!({"point":[p.x,p.y],"source":true})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![line]);
}
#[test]
fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() {
for exits in [false, true] {
let mut s = session();
let mut originals = vec![];
for (x, y, fill) in [(100.0, 20.0, "#ff0000"), (20.0, 130.0, "#ffffff"), (170.0, 130.0, "#ffff00")] {
s.execute("paint.setFill", &json!({"color":fill,"ids":[]})).unwrap();
let id = NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":y,"width":200,"height":200})).unwrap()["id"].as_u64().unwrap());
originals.push(node(&s, id).clone());
}
let mut points = vec![[290.0, 210.0], [330.0, 225.0], [290.0, 240.0], [330.0, 255.0]];
if exits {
points.push([410.0, 260.0]);
}
let g = scribble(&mut s, &points);
for (x, y) in [(330.0, 230.0), (190.0, 230.0), (250.0, 180.0)] {
assert_eq!(fill(&s, g, x, y), None, "all of the visible yellow face is erased, including covered intersections");
}
assert_eq!(stroke(&s, g, 370.0, 230.0), !exits);
for angle in [145.0_f64, 160.0, 180.0, 200.0, 220.0, 240.0, 260.0, 280.0] {
let (sin, cos) = angle.to_radians().sin_cos();
assert!(stroke(&s, g, 270.0 + 100.0 * cos, 230.0 + 100.0 * sin), "shared curve remains complete at {angle}°, exiting {exits}");
}
if exits {
// The two new corners must be actual path joins, rather than meeting butt caps.
for corner in [Point::new(298.975, 134.288), Point::new(195.0, 296.144)] {
assert!(
node(&s, g)
.children()
.unwrap()
.iter()
.filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)))
.flat_map(|n| n.path_data().unwrap().subpaths.iter())
.flat_map(|sp| &sp.anchors)
.any(|a| a.p.distance(corner) < 0.1 && a.has_in() && a.has_out()),
"joined corner at {corner:?}"
);
}
}
assert_eq!(fill(&s, g, 110.0, 230.0).as_deref(), Some("#ffffff"));
assert_eq!(fill(&s, g, 200.0, 80.0).as_deref(), Some("#ff0000"));
for n in originals {
assert_eq!(node(&s, n.id), &n);
}
}
}
#[test]
fn shaper_visible_dividing_stroke_keeps_fill_erasure_on_one_side() {
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00","ids":[]})).unwrap();
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
s.execute("shape.line", &json!({"x1":100,"y1":150,"x2":200,"y2":150})).unwrap();
let g = scribble(&mut s, &[[140.0, 125.0], [160.0, 135.0], [140.0, 140.0], [160.0, 145.0]]);
assert_eq!(fill(&s, g, 150.0, 130.0), None);
assert_eq!(fill(&s, g, 150.0, 170.0).as_deref(), Some("#ffff00"));
assert!(stroke(&s, g, 150.0, 150.0));
}
#[test]
fn shaper_joined_outline_keeps_its_stroke_paint_after_source_edits() {
let mut s = session();
let mut ids = vec![];
for (x, color) in [(0, "#ff0000"), (50, "#ffff00")] {
s.execute("paint.setFill", &json!({"color":color,"ids":[]})).unwrap();
ids.push(NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap()));
}
let g = scribble(&mut s, &[[20.0, 40.0], [70.0, 45.0], [30.0, 50.0], [125.0, 55.0], [75.0, 60.0], [125.0, 65.0]]);
let outlines: Vec<_> =
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
assert_eq!(outlines.len(), 1);
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
let outline = outlines[0].id;
s.execute("paint.setStroke", &json!({"color":"#00ff00","ids":[outline.0]})).unwrap();
s.execute("object.move", &json!({"ids":[ids[0].0],"dx":10,"dy":0})).unwrap();
let outlines: Vec<_> =
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
assert_eq!(outlines.len(), 1);
assert_eq!(outlines[0].appearance.stroke_paint().label(), "#00ff00");
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
for id in ids {
assert_ne!(node(&s, id).appearance.stroke_paint().label(), "#00ff00");
}
}
+109
View File
@@ -0,0 +1,109 @@
//! Smart Guides for the drawing tools, driven through the session (#506): where the Rectangle
//! tool's corners and the Pen's anchors land, and the guides they show.
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
use super::*;
use crate::tooling::ViewInfo;
/// An 800 × 600 document with a 100 × 100 square at (100, 100), nothing selected.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s
}
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
}
fn labels(s: &mut Session) -> Vec<(String, Point)> {
s.overlays(ViewInfo::default()).into_iter().filter_map(|o| if let Overlay::Label { text, p, .. } = o { Some((text, p)) } else { None }).collect()
}
/// The bounds of the object the last gesture made (it is selected).
fn made(s: &Session) -> Rect {
let d = s.doc().unwrap();
let id: NodeId = d.selection.objects[0];
d.doc.node(id).unwrap().geometric_bounds().unwrap()
}
#[test]
fn a_rectangle_corner_lands_on_another_objects_anchor() {
let mut s = session();
s.select_tool("rectangle", ViewInfo::default()).unwrap();
// Hovering near the square's corner already says where the press goes.
pointer(&mut s, PointerKind::Move, 302.0, 297.0);
pointer(&mut s, PointerKind::Move, 202.0, 203.0);
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
pointer(&mut s, PointerKind::Down, 302.0, 297.0);
pointer(&mut s, PointerKind::Drag, 250.0, 250.0);
pointer(&mut s, PointerKind::Drag, 202.0, 203.0);
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
let measure = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Measure { text, .. } = o { Some(text) } else { None });
assert!(measure.as_deref().is_some_and(|t| t.starts_with("W: 102") && t.contains("H: 100")), "{measure:?}");
pointer(&mut s, PointerKind::Up, 202.0, 203.0);
// The start lined up with the artboard's centre (y = 300), the corner on the anchor.
assert_eq!(made(&s), Rect::new(200.0, 200.0, 302.0, 300.0));
}
/// The corner being dragged never snaps to the rectangle's own preview: dragged slowly, it follows
/// the pointer instead of sticking where the last step left it.
#[test]
fn a_rectangle_does_not_snap_to_itself_while_drawn() {
let mut s = session();
s.select_tool("rectangle", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 320.0, 420.0);
for x in 370..=380 {
pointer(&mut s, PointerKind::Drag, f64::from(x), 470.0 + f64::from(x - 370));
}
pointer(&mut s, PointerKind::Up, 380.0, 480.0);
assert_eq!(made(&s), Rect::new(320.0, 420.0, 380.0, 480.0));
}
#[test]
fn a_pen_anchor_lines_up_with_another_objects_centre() {
let mut s = session();
s.select_tool("pen", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 300.0, 420.0);
pointer(&mut s, PointerKind::Up, 300.0, 420.0);
// Hovering: in line with the square's centre (150, 150), the guide running from it.
pointer(&mut s, PointerKind::Move, 151.5, 431.0);
let ov = s.overlays(ViewInfo::default());
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if *a == Point::new(150.0, 150.0) && *b == Point::new(150.0, 431.0))), "{ov:?}");
assert_eq!(labels(&mut s), [("align".to_string(), Point::new(150.0, 431.0))]);
pointer(&mut s, PointerKind::Down, 151.5, 431.0);
pointer(&mut s, PointerKind::Up, 151.5, 431.0);
// Shift: along the 45° step from it, slid into line with the square's left side (x = 100).
s.pointer(&PointerEvent::new(PointerKind::Down, 103.0, 386.0).with_mods(Mods { shift: true, ..Mods::default() }), ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Up, 103.0, 386.0);
let d = s.doc().unwrap();
let path = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap();
let pts: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts[..2], [Point::new(300.0, 420.0), Point::new(150.0, 431.0)]);
assert!((pts[2] - Point::new(100.0, 381.0)).hypot() < 1e-9, "{pts:?}");
}
/// Preferences › Smart Guides › Construction Guides: on (the default), a Pen anchor near the
/// 45° guide through the last one lands on it; off, it stays where it was clicked.
#[test]
fn construction_guides_follow_the_preference() {
for on in [true, false] {
let mut s = session();
s.execute("prefs.set", &json!({"key": "constructionGuides", "value": on})).unwrap();
s.select_tool("pen", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 500.0, 500.0);
pointer(&mut s, PointerKind::Up, 500.0, 500.0);
pointer(&mut s, PointerKind::Down, 561.0, 438.0);
pointer(&mut s, PointerKind::Up, 561.0, 438.0);
let d = s.doc().unwrap();
let last = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[1].p;
let want = if on { Point::new(561.5, 438.5) } else { Point::new(561.0, 438.0) };
assert!((last - want).hypot() < 1e-9, "{on}: {last:?}");
}
}
+68
View File
@@ -197,6 +197,74 @@ fn another_documents_swatches_load_as_a_library() {
assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err());
}
#[test]
fn swatch_exchange_files_load_add_and_list_as_user_libraries() {
let bytes = vectorcraft_testkit::ase::sample();
let mut s = session();
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Brand.ase"}));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some("loaded/Brand.ase"), Some("Brand"), Some(4)));
let g = run(&mut s, "swatch.library.get", json!({"library": r["library"]}));
let swatches = g["swatches"].as_array().unwrap();
let kinds: Vec<(&str, bool, bool)> =
swatches.iter().map(|w| (w["name"].as_str().unwrap(), w["global"].as_bool().unwrap(), w["spot"].as_bool().unwrap())).collect();
assert_eq!(kinds, [("Sky", true, false), ("Ink", true, true), ("Mist", false, false), ("Clay", false, false)]);
assert_eq!(swatches[3]["color"], json!({"model": "lab", "l": 50.0, "a": 20.0, "b": -30.0}));
// Added to the document, the spot color stays a spot color and the group stays a color group.
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
let d = doc(&s);
assert!(d.swatch("Ink").is_some_and(|w| w.spot && w.global));
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Neutrals").map(|g| g.swatches.len()), Some(2));
// A damaged file is an error from the swatch exchange reader, not from the document loader.
let cut = vectorcraft_format::base64_encode(&bytes[..bytes.len() - 1]);
let e = s.execute("swatch.library.load", &json!({"dataBase64": cut, "name": "Cut.ase"})).unwrap_err().to_string();
assert!(e.contains("cut short"), "{e}");
// `.ase` files in the user library folder are User Defined libraries.
let dir = temp_dir("ase");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Brand.ase"), &bytes).unwrap();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let list = run(&mut s, "swatch.library.list", json!({}));
let user: Vec<(&str, u64)> = list["libraries"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["category"] == "user")
.map(|l| (l["id"].as_str().unwrap(), l["count"].as_u64().unwrap()))
.collect();
assert_eq!(user, [("user/Brand.ase", 4)]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_library_group_stays_one_group_when_a_swatch_has_its_name() {
use vectorcraft_testkit::ase::{Block, ase};
let mut s = session();
assert!(doc(&s).swatch("Blue").is_some(), "a new document has a swatch named Blue");
let bytes = ase(&[
Block::GroupStart("Blue"),
Block::Color("Deep", b"RGB ", &[0.0, 0.0, 0.4], 2),
Block::Color("Pale", b"RGB ", &[0.6, 0.7, 1.0], 2),
Block::GroupEnd,
Block::GroupStart("Blue 2"),
Block::Color("Frost", b"RGB ", &[0.9, 0.95, 1.0], 2),
Block::GroupEnd,
]);
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Blues.ase"}));
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
let groups: Vec<(&str, Vec<&str>)> =
doc(&s).swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert!(groups.contains(&("Blue 2", vec!["Deep", "Pale"])), "{groups:?}");
assert!(groups.contains(&("Blue 2 2", vec!["Frost"])), "the library's own Blue 2 stays apart: {groups:?}");
}
#[test]
fn text_libraries_sent_as_bytes_load_when_they_are_not_valid_utf8() {
let mut s = session();
let latin1 = vectorcraft_format::base64_encode(b"GIMP Palette\n237 28 36 Rouge fonc\xe9\n");
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": latin1, "name": "Rouge.gpl"}));
assert_eq!(r["count"], 1);
}
#[test]
fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradients() {
let mut s = session();
+35
View File
@@ -339,3 +339,38 @@ fn the_type_widget_converts_point_and_area_type() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), before);
}
#[test]
fn area_type_options_align_the_lines_vertically() {
use vectorcraft_doc::VerticalAlign;
let mut s = session();
let id = area(&mut s, 40.0, 40.0, 200.0, 300.0, "Short story");
sel(&mut s, &[id]);
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["verticalAlign"], "top");
let top = layout(&s, id).lines[0].baseline;
let before = text(&s, id).cached_bounds;
let n = undo_len(&s);
let r = s.execute("text.areaOptions", &json!({"verticalAlign": "bottom"})).unwrap();
assert_eq!(r["verticalAlign"], "bottom");
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Bottom);
let l = layout(&s, id);
let last = l.lines.last().unwrap();
assert!((last.baseline + last.descent - 300.0).abs() < 1e-6);
assert!(l.lines[0].baseline > top + 200.0);
assert_ne!(text(&s, id).cached_bounds, before, "the cached bounds follow the text");
assert_eq!((undo_len(&s), last_step(&s).as_str()), (n + 1, "Area Type Options"));
s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap();
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center);
// Unknown values are refused and change nothing.
for bad in [json!({"verticalAlign": "middle"}), json!({"verticalAlign": 3}), json!({"verticalAlign": null})] {
assert!(s.execute("text.areaOptions", &bad).is_err(), "{bad}");
}
assert_eq!(undo_len(&s), n + 2);
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Top);
assert_eq!(layout(&s, id).lines[0].baseline, top);
assert_eq!(text(&s, id).cached_bounds, before);
assert!(cmd::find_command("text.areaOptions").unwrap().params.contains("verticalAlign?: top|center|bottom|justify"));
}
+5 -2
View File
@@ -247,10 +247,13 @@ fn artboard_tool_moves_with_art_creates_and_deletes() {
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
assert_eq!(s.tool_options()["active"], 1);
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0));
// Delete it.
// Delete it: the tool takes the key ahead of the Clear shortcut (the UI only hands Delete to a
// tool that claims it).
assert!(s.tool_claims_key(ToolKey::Delete, ViewInfo::default()));
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
// The last artboard can't be deleted.
// The last artboard can't be deleted, so Delete stays the shortcut's.
assert!(!s.tool_claims_key(ToolKey::Delete, ViewInfo::default()));
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
}
+7
View File
@@ -178,6 +178,7 @@ impl Session {
let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit());
let cx = ToolContext {
doc: &st.doc,
revision: (st.uid, st.revision),
selection: &st.selection,
zoom: view.zoom,
isolation: st.isolation,
@@ -204,6 +205,7 @@ impl Session {
slices_locked: self.menu.slices_locked,
auto_add_delete: !self.prefs.disable_auto_add_delete,
selection_tolerance: self.prefs.selection_tolerance,
anchor_size: self.prefs.anchor_size,
path_only: self.prefs.object_selection_by_path_only,
type_path_only: self.prefs.type_selection_by_path_only,
double_click_isolate: self.prefs.double_click_to_isolate,
@@ -222,6 +224,11 @@ impl Session {
measurement_labels: self.prefs.measurement_labels,
transform_tools_guides: self.prefs.transform_tools_guides,
snapping_tolerance: self.prefs.snapping_tolerance,
construction_angles: if self.prefs.construction_guides {
vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles)
} else {
&[]
},
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
+135
View File
@@ -274,6 +274,15 @@ fn structured_junk() {
("text.create", json!({"x": 0, "y": 0, "text": "\u{0}\u{FFFF}\u{1F600}", "size": -1})),
("text.create", json!({"x": 0, "y": 0, "text": "a", "size": 1e308, "area": {"width": -1, "height": 0}})),
("text.setStyle", json!({"size": 0, "leading": -1, "tracking": 1e308})),
("text.setStyle", json!({"justify": "center", "start": u64::MAX, "end": 0})),
("text.setStyle", json!({"justify": "right", "start": -1, "end": "x"})),
("text.setFormat", json!({"spaceBefore": 1e308, "leftIndent": -1e308, "start": 3, "end": u64::MAX})),
("text.setFormat", json!({"hyphenate": true, "underline": true, "start": 1.5, "end": null})),
("text.setFormat", json!({"ids": [u64::MAX, 2], "firstLineIndent": 5, "start": 0, "end": 0})),
("text.tabs.set", json!({"stops": [{"position": 10}], "start": u64::MAX, "end": u64::MAX})),
("text.tabs.get", json!({"start": -5})),
("paraStyle.apply", json!({"name": "[Normal Paragraph Style]", "id": 2, "start": u64::MAX, "end": 1})),
("paraStyle.new", json!({"id": 2, "start": u64::MAX})),
("document.setUnits", json!({"units": "Parsecs"})),
("document.open", json!({"name": "x.svg", "dataBase64": "!!!"})),
("document.open", json!({"name": "x.svg", "dataBase64": "PHN2Zz4="})),
@@ -311,6 +320,54 @@ fn structured_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// Area Type Options with junk fit values, on selected area type (the fixtures have none), with
/// text that overflows: never a panic, the document stays sound, and editing still works.
#[test]
fn area_options_fit_junk() {
let cases = [
json!({"fit": "shrinkText", "fitMinPercent": 1e308}),
json!({"fit": "shrinkText", "fitMinPercent": -1e308}),
json!({"fit": "shrinkText", "fitMinPercent": "x"}),
json!({"fit": {"shrinkText": {"minPercent": -5}}}),
json!({"fit": {"shrinkText": {"minPercent": "a"}}}),
json!({"fit": {"shrinkText": null}}),
json!({"fit": {"autoHeight": 1}}),
json!({"fit": {"bogus": {}}}),
json!({"fit": [1, 2]}),
json!({"fit": ""}),
json!({"fit": "AUTO-HEIGHT", "height": 1e308, "width": -1e308}),
json!({"fit": "autoHeight", "columns": u64::MAX, "gutter": 1e308, "inset": 1e308}),
json!({"fit": "autoHeight", "rows": 5, "inset": -1}),
json!({"fit": "shrinkText", "columns": 100, "gutter": 0, "inset": 10000}),
json!({"fitMinPercent": 50}),
json!({"fit": "none", "fitMinPercent": 1e-308}),
];
let story = "Words that overflow a small frame. ".repeat(40);
let mut failures = vec![];
for (size, (w, h)) in [(12.0, (120.0, 40.0)), (0.1, (1.0, 1.0)), (1296.0, (100_000.0, 1.0))] {
for p in &cases {
let mut s = Fixture::Multi.session();
let made = s.execute("text.create", &json!({"x": 10, "y": 10, "size": size, "text": story, "area": {"width": w, "height": h}}));
let Ok(made) = made else { continue };
let id = made["id"].as_u64().unwrap_or(0);
let r = catch_quiet(|| s.execute("text.areaOptions", p));
match r {
Err(m) => failures.push(format!("PANIC text.areaOptions {p} [{size} pt, {w}×{h}]: {m}")),
Ok(_) => {
let typed = catch_quiet(|| s.execute("text.editRange", &json!({"id": id, "start": 0, "insert": "More words. "})));
if typed.is_err() {
failures.push(format!("PANIC typing after text.areaOptions {p}"));
}
if let Err(e) = check_session(&s) {
failures.push(format!("text.areaOptions {p} [{size} pt]: {e}"));
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// After a successful fuzzed call, the document still round-trips and exports.
#[test]
fn fuzzed_calls_keep_documents_serializable() {
@@ -444,3 +501,81 @@ fn envelope_commands_with_junk_params_on_envelopes() {
failures.dedup();
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
}
/// Inline graphics in text (a symbol set in a run of type): text commands with `{}`, junk and
/// structured junk (bad symbol names, offsets inside or past the graphic's character, malformed
/// inline runs) never panic, leave an interaction open or break the tree, and the document still
/// round-trips and exports.
#[test]
fn text_commands_with_junk_params_on_inline_graphics() {
use vectorcraft_testkit::fixtures::{ellipse, exec, id_of, select};
let path = safe_path();
let setup = || {
let mut s = Fixture::Multi.session();
let e = ellipse(&mut s, 400.0, 300.0, 20.0, 20.0);
select(&mut s, &[e]);
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "ab cd", "size": 20})));
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 2, "symbol": "Dot", "scale": 2}));
select(&mut s, &[t]);
(s, t)
};
let (_, t) = setup();
let t = t.0;
let style = json!({"font_family": "Source Sans 3", "size": 12, "fill": {"type": "none"}});
let structured = [
("text.insertInline", json!({"id": t, "at": u64::MAX, "symbol": "Dot", "scale": 100})),
("text.insertInline", json!({"id": t, "at": 3, "symbol": "Dot", "scale": 1e-300, "shift": -1e5})),
("text.insertInline", json!({"id": t, "at": 3, "symbol": "\u{0}", "scale": 1})),
("text.insertInline", json!({"id": t, "at": 3, "symbol": "", "scale": f64::MAX})),
("text.insertInline", json!({"id": u64::MAX, "symbol": "Dot"})),
("text.insertInline", json!({"id": t, "at": "3", "symbol": ["Dot"], "scale": "big", "shift": null})),
("text.editRange", json!({"id": t, "start": 3, "end": 4, "insert": "x"})),
("text.editRange", json!({"id": t, "start": 4, "end": 3})),
(
"text.editRange",
json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}\u{FFFC}x", "style": style, "inline": {"symbol": "Dot", "scale": 1e308, "baseline_shift": -1e308}}]}),
),
("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": "Missing"}}]})),
("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "", "style": style, "inline": {"symbol": "Dot", "scale": -5}}]})),
("text.editRange", json!({"id": t, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": 5}}]})),
("text.setRangeStyle", json!({"id": t, "start": 3, "end": 4, "size": 1e308, "tracking": -1e308})),
("text.getRange", json!({"id": t, "start": 3, "end": 4})),
("type.insert", json!({"text": "\u{FFFC}"})),
("type.changeCase", json!({"case": "upper"})),
("type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})),
("type.convertToAreaType", json!({})),
("type.createOutlines", json!({})),
("symbol.delete", json!({"name": "Dot"})),
("symbol.delete", json!({"name": "Dot", "expandInstances": false})),
("symbol.update", json!({"name": "Dot"})),
];
let mut failures = vec![];
let mut run = |id: &str, p: &Value| {
let (mut s, _) = setup();
match catch_quiet(|| s.execute(id, p)) {
Err(msg) => failures.push(format!("PANIC {id} {p}: {msg}")),
Ok(r) => {
if s.in_interaction() {
failures.push(format!("{id} {p}: left an interaction open"));
} else if let Err(e) = check_session(&s) {
failures.push(format!("{id} {p}: {e}"));
} else if r.is_ok()
&& let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}")))
{
failures.push(format!("{id} {p}: {e}"));
}
}
}
};
for (id, p) in &structured {
run(id, p);
}
for id in ["text.insertInline", "text.editRange", "text.getRange", "text.setRangeStyle", "type.insert"] {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
run(id, &p);
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
+91 -19
View File
@@ -1,5 +1,5 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
@@ -529,12 +529,21 @@ fn saved(cmd: &str, params: Value) -> String {
rich_session().execute(cmd, &params).unwrap()["data"].as_str().unwrap().to_string()
}
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
/// `EngineError::Internal`.
fn no_panic(r: Result<Value, vectorcraft_engine::EngineError>) -> Result<Value, vectorcraft_engine::EngineError> {
if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r {
panic!("{cmd} panicked: {msg}");
}
r
}
/// Library file `data` loaded with `load`; what it holds is then used on the rich document
/// (`then`, given the load's result), which renders and exports.
fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut vectorcraft_engine::Session, &Value)) -> Result<(), TestCaseError> {
survive(what, || {
let mut s = rich_session();
let r = s.execute(load, &json!({"data": data, "name": "fuzz"})).ok()?;
let r = no_panic(s.execute(load, &json!({"data": data, "name": "fuzz"}))).ok()?;
then(&mut s, &r);
Some((*s.doc().ok()?.doc).clone())
})
@@ -542,27 +551,55 @@ fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut ve
fn swatches(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "swatch.library.load", data, |s, r| {
let _ = s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"}));
let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"})));
})
}
fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "graphicStyle.loadLibrary", data, |s, r| {
let _ = s.execute("select.all", &json!({}));
let _ = s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true}));
let _ = no_panic(s.execute("select.all", &json!({})));
let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true})));
})
}
fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "flattener.presets.import", data, |s, r| {
let _ = s.execute("select.all", &json!({}));
let _ = no_panic(s.execute("select.all", &json!({})));
if let Some(name) = r["imported"].get(0) {
let _ = s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"}));
let _ = s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36}));
let _ = no_panic(s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"})));
let _ = no_panic(s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36})));
}
})
}
/// Swatch exchange (`.ase`) bytes loaded as a swatch library, then added to the rich document.
fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> {
survive(what, || {
let mut s = rich_session();
let load = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.ase"});
let r = no_panic(s.execute("swatch.library.load", &load)).ok()?;
let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"})));
Some((*s.doc().ok()?.doc).clone())
})
}
/// A random swatch exchange block (type, body). The body holds a name, a color model when the block
/// is a color, and random bytes for the rest.
fn arb_ase_block() -> impl Strategy<Value = (u16, Vec<u8>)> {
let kind = prop::sample::select(vec![0xC001u16, 0xC002, 0x0001, 0x0042]);
let model = prop::sample::select(vec![*b"RGB ", *b"CMYK", *b"LAB ", *b"Gray", *b"HSB "]);
(kind, "[ -~]{0,8}", model, prop::collection::vec(any::<u8>(), 0..24)).prop_map(|(kind, name, model, rest)| {
let units: Vec<u16> = name.encode_utf16().chain([0]).collect();
let mut body = (units.len() as u16).to_be_bytes().to_vec();
body.extend(units.iter().flat_map(|u| u.to_be_bytes()));
if kind == 0x0001 {
body.extend(model);
}
body.extend(rest);
(kind, body)
})
}
/// Characters that break JSON and GPL palettes.
fn arb_edit() -> impl Strategy<Value = (usize, char)> {
(0usize..20_000, prop::sample::select(vec!['{', '}', '[', ']', '"', ':', ',', '-', '9', 'e', '.', ' ', '\n', '#', 'n', 'x', '\t']))
@@ -596,6 +633,32 @@ proptest! {
let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]}));
flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?;
}
#[test]
fn swatch_exchange_garbage_never_panics(blocks in prop::collection::vec(arb_ase_block(), 0..12), tail in prop::collection::vec(any::<u8>(), 0..16)) {
let mut bytes = b"ASEF\0\x01\0\0".to_vec();
bytes.extend((blocks.len() as u32).to_be_bytes());
for (kind, body) in &blocks {
bytes.extend(kind.to_be_bytes());
bytes.extend((body.len() as u32).to_be_bytes());
bytes.extend(body);
}
bytes.extend(tail);
swatch_exchange("swatch exchange garbage", &bytes)?;
}
#[test]
fn mutated_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..200), edits in prop::collection::vec((0usize..200, any::<u8>()), 0..12)) {
let mut bytes = vectorcraft_testkit::ase::sample();
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
if let Some(cut) = cut {
bytes.truncate(cut);
}
swatch_exchange("mutated swatch exchange file", &bytes)?;
}
}
/// A small image of each raster format Place reads, with resolution metadata where it has some.
@@ -704,8 +767,8 @@ proptest! {
fn pdf_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "pdf.preset.import", data, |s, r| {
for name in r["imported"].as_array().into_iter().flatten() {
let _ = s.execute("document.exportPdf", &json!({"preset": name}));
let _ = s.execute("document.save", &json!({"format": "ai", "preset": name}));
let _ = no_panic(s.execute("document.exportPdf", &json!({"preset": name})));
let _ = no_panic(s.execute("document.save", &json!({"format": "ai", "preset": name})));
}
})
}
@@ -878,7 +941,8 @@ proptest! {
}
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
/// as Illustrator's too, its groups (`u` … `U`) unbalanced, deep and among clips.
/// as files in the legacy Illustrator format too (the creator line is what turns on its
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips.
#[test]
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>()) {
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
@@ -952,7 +1016,13 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"/T3 << /FontType 3 /FontMatrix [0.1 0 0 0.1 0 0] /FontBBox [0 0 9 9] /Encoding [/a /b] /BuildChar { pop pop 9 0 setcharwidth 0 0 5 5 rectfill } >> definefont setfont",
"/T4 << /FontType 3 /FontMatrix [1 0 0 1 0 0] /BuildGlyph { pop pop (x) show } /Encoding [/a] >> definefont setfont",
"glyphshow", "xshow", "xyshow", "awidthshow", "kshow", "/a", "[1 2 3]",
// Executable strings and integer keys (Illustrator 8's procsets).
// Several data sources reading one file in turn, and sources of uneven lengths.
"/f currentfile /ASCIIHexDecode filter def", "{f 3 string readstring pop}", "{f 0 string readstring pop}", "true 3 colorimage",
"3 2 8 [3 0 0 2 0 0] {f 5 string readstring pop} {(ab)} {f 1 string readstring pop} true 3 colorimage",
"<< /ImageType 1 /Width 3 /Height 2 /BitsPerComponent 4 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [3 0 0 2 0 0] /MultipleDataSources true /DataSource [(a) (bc) {f 2 string readstring pop} (d)] >>",
// Local VM restored: dictionary changes undone, saves restored twice.
"/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store",
// Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`).
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
])
.prop_map(str::to_string),
@@ -1212,14 +1282,14 @@ proptest! {
/// each one imported.
fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "print.presets.import", data, |s, r| {
let list = s.execute("print.presets.list", &json!({})).unwrap_or_default();
let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default();
for name in r["imported"].as_array().into_iter().flatten() {
let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue };
let settings = json!({ "settings": p["settings"] });
let pages = s.execute("print.preview", &settings).ok().and_then(|v| v["pages"].as_u64());
let _ = s.execute("print.setup", &settings);
let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64());
let _ = no_panic(s.execute("print.setup", &settings));
if pages.is_some_and(|n| n <= 8) {
let _ = s.execute("file.print", &settings);
let _ = no_panic(s.execute("file.print", &settings));
}
}
})
@@ -1249,9 +1319,11 @@ proptest! {
fn perspective_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "perspective.presets.import", data, |s, r| {
for name in r["imported"].as_array().into_iter().flatten() {
let _ = s.execute("perspective.grid.preset", &json!({"name": name}));
let _ = s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3}));
let _ = s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}}));
let _ = no_panic(s.execute("perspective.grid.preset", &json!({"name": name})));
let _ = no_panic(s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3})));
let _ = no_panic(
s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}})),
);
}
})
}
+139
View File
@@ -0,0 +1,139 @@
//! Inline graphics in text drawn on the canvas and written to SVG and PDF: a symbol (a red
//! circle drawn in code) set inline in a line of type.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, TextObject};
use vectorcraft_geom::Rect;
use vectorcraft_testkit::fixtures::{self, exec, id_of};
use vectorcraft_testkit::raster;
/// A session with a red circle symbol "Dot" (its instance deleted) and the point type
/// "Tap now" (20 pt, baseline at y = 100) with the symbol inline after "Tap ".
fn setup(inline: bool) -> (vectorcraft_engine::Session, NodeId) {
let mut s = fixtures::session_with(400.0, 300.0);
fixtures::ellipse(&mut s, 300.0, 250.0, 20.0, 20.0);
exec(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
exec(&mut s, "paint.setStroke", json!({"none": true}));
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
exec(&mut s, "edit.clear", json!({}));
// Type is painted with the current fill: black, not the circle's red.
exec(&mut s, "paint.setFill", json!({"color": "#000000"}));
let id = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Tap now", "size": 20})));
if inline {
exec(&mut s, "text.insertInline", json!({"id": id.0, "at": 4, "symbol": "Dot"}));
}
exec(&mut s, "select.none", json!({}));
(s, id)
}
fn doc(s: &vectorcraft_engine::Session) -> Document {
(*s.doc().unwrap().doc).clone()
}
fn text(d: &Document, id: NodeId) -> TextObject {
match &d.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
/// Where the inline art lands, in document space.
fn art_rect(d: &Document, id: NodeId) -> Rect {
let t = text(d, id);
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert_eq!(l.inlines.len(), 1);
t.xf.transform_rect_bbox(l.inlines[0].bounds)
}
fn red(p: [u8; 3]) -> bool {
p[0] > 200 && p[1] < 80 && p[2] < 80
}
#[test]
fn canvas_draws_the_symbol_where_the_layout_puts_it() {
let (s, id) = setup(true);
let d = doc(&s);
let r = art_rect(&d, id);
// 20 pt tall at 20 pt type, on the pen after "Tap ", centred on the cap-height middle.
assert!((r.height() - 20.0).abs() < 0.5 && (r.width() - 20.0).abs() < 0.5, "{r:?}");
assert!(r.x0 > 50.0 && r.x0 < 70.0, "{r:?}");
assert!(r.center().y < 100.0 && r.center().y > 90.0, "{r:?}");
let img = raster::render_region(&d, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let px = |x: f64, y: f64| img.over_white((x * 2.0) as u32, (y * 2.0) as u32);
assert!(red(px(r.center().x, r.center().y)), "{:?} at {:?}", px(r.center().x, r.center().y), r.center());
// Only there: just outside the circle, no red.
assert!(!red(px(r.x0 - 3.0, r.center().y)) && !red(px(r.center().x, r.y0 - 3.0)));
// Without the graphic nothing is red, and the text after it sits further left.
let (plain, pid) = setup(false);
let pd = doc(&plain);
let img = raster::render_region(&pd, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
assert_eq!(img.count(|p| p[0] > 200 && p[1] < 80 && p[2] < 80 && p[3] > 200), 0);
let w = text(&d, id).bounds().unwrap().width() - text(&pd, pid).bounds().unwrap().width();
assert!((w - 20.0).abs() < 0.5, "{w}");
}
#[test]
fn canvas_applies_the_text_objects_opacity_to_its_inline_art() {
let (mut s, id) = setup(true);
exec(&mut s, "select.set", json!({"ids": [id.0]}));
exec(&mut s, "transparency.set", json!({"opacity": 50}));
let d = doc(&s);
let r = art_rect(&d, id);
let img = raster::render_region(&d, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let p = img.over_white((r.center().x * 2.0) as u32, (r.center().y * 2.0) as u32);
// Half red over white: pink.
assert!(p[0] > 240 && (100..180).contains(&p[1]) && (100..180).contains(&p[2]), "{p:?}");
}
fn svg_of(s: &mut vectorcraft_engine::Session, params: Value) -> String {
let v = exec(s, "document.serialize", params);
v["text"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap())
}
#[test]
fn svg_keeps_live_text_and_draws_the_symbol() {
let (mut s, id) = setup(true);
let svg = svg_of(&mut s, json!({"format": "svg", "outlineText": false}));
// Live text without the object replacement character; the text after the graphic is placed
// after it.
assert!(svg.contains("<text") && svg.contains(">Tap") && svg.contains("now<"), "{svg}");
assert!(!svg.contains('\u{FFFC}'), "{svg}");
// The symbol's art: a <symbol> def and a <use> of it.
assert!(svg.contains("<symbol id=\"Dot\""), "{svg}");
assert!(svg.matches("xlink:href=\"#Dot\"").count() == 1, "{svg}");
// Re-imported, the art is a red circle where the canvas draws it.
let back = vectorcraft_svg::import(&svg).unwrap();
let img = raster::render_region(&back, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let r = art_rect(&doc(&s), id);
let p = img.over_white((r.center().x * 2.0) as u32, (r.center().y * 2.0) as u32);
assert!(red(p), "{p:?}");
// Text as outlines and fewer tspans still draw it.
for params in [json!({"format": "svg", "outlineText": true}), json!({"format": "svg", "outlineText": false, "fewerTspans": true})] {
let svg = svg_of(&mut s, params.clone());
assert!(svg.contains("xlink:href=\"#Dot\""), "{params}: {svg}");
assert!(!svg.contains('\u{FFFC}'), "{params}");
}
}
#[test]
fn pdf_draws_the_symbol_art() {
let (mut s, id) = setup(true);
let v = exec(&mut s, "document.serialize", json!({"format": "pdf"}));
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let back = vectorcraft_pdf::import(&bytes).unwrap();
let r = art_rect(&doc(&s), id);
let img = raster::render_region(&back, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let p = img.over_white((r.center().x * 2.0) as u32, (r.center().y * 2.0) as u32);
assert!(red(p), "{p:?} at {:?}", r.center());
// Without it, nothing red.
let (mut plain, _) = setup(false);
let v = exec(&mut plain, "document.serialize", json!({"format": "pdf"}));
let back = vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
let img = raster::render_region(&back, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
assert_eq!(img.count(|p| p[0] > 200 && p[1] < 80 && p[2] < 80 && p[3] > 200), 0);
}
+44 -30
View File
@@ -635,33 +635,51 @@ impl Interp<'_> {
/// The bytes a data procedure gives, called until it gives an empty string or `n` bytes.
fn gather(&mut self, p: &Obj, n: usize) -> Res<Vec<u8>> {
let mut out = vec![];
while out.len() < n {
self.call(p.clone())?;
let s = self.pop_str()?;
let s = s.borrow();
if s.is_empty() {
break;
}
self.alloc(s.len())?;
out.extend_from_slice(&s);
}
out.truncate(n);
Ok(out)
Ok(self.read_sources(&[Source::Proc(p.clone())], n, n)?.pop().unwrap_or_default())
}
/// `n` bytes of image data from `src` (zeros past its end).
fn source_bytes(&mut self, src: &Source, n: usize) -> Res<Vec<u8>> {
let mut v = match src {
Source::File(f) => self.read(f, n)?,
Source::Str(s) if !s.is_empty() => s.iter().copied().cycle().take(n).collect(),
Source::Str(_) => vec![],
Source::Proc(p) => self.gather(p, n)?,
};
let mut v = self.read_sources(std::slice::from_ref(src), n, n)?.pop().unwrap_or_default();
v.resize(n, 0);
Ok(v)
}
/// Up to `n` bytes from each of `sources`, read in turn as an image reads several data sources
/// (PLRM 3rd ed., §4.10 "Images" and `image`/`colorimage` in chapter 8: the sources are read
/// in turn, the same amount from each): each procedure called once a round (its string,
/// whatever its length), each file and string giving `step` bytes a round, until each has
/// given `n` bytes or ended. Procedures that read one file get their own rows that way.
fn read_sources(&mut self, sources: &[Source], n: usize, step: usize) -> Res<Vec<Vec<u8>>> {
let mut out: Vec<Vec<u8>> = vec![vec![]; sources.len()];
let mut ended = vec![false; sources.len()];
while out.iter().zip(&ended).any(|(o, e)| o.len() < n && !e) {
for ((src, o), e) in sources.iter().zip(out.iter_mut()).zip(ended.iter_mut()) {
if *e || o.len() >= n {
continue;
}
let want = step.min(n - o.len());
let chunk = match src {
Source::File(f) => self.read(f, want)?,
// A string gives its bytes over and over.
Source::Str(s) => {
let at = o.len() % s.len().max(1);
s.get(at..).unwrap_or_default().iter().chain(s.iter().cycle()).take(want).copied().collect()
}
Source::Proc(p) => {
self.call(p.clone())?;
let s = self.pop_str()?.to_vec();
self.alloc(s.len())?;
s
}
};
*e = chunk.is_empty();
o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default());
}
}
Ok(out)
}
/// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's
/// fonts, by name): the font dictionary it would complete is defined as it is.
fn eexec(&mut self) -> Res {
@@ -743,7 +761,7 @@ impl Interp<'_> {
let Some(mut img) = self.decode_image(image)? else { return Ok(None) };
let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) };
let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize);
for (i, px) in img.rgba.chunks_exact_mut(4).enumerate() {
for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
// The mask's pixel over this one (in 64 bits: rows times rows overflow 32).
let at = (y as u64 * mh as u64 / h.max(1) as u64) * mw as u64 + x as u64 * mw as u64 / w.max(1) as u64;
@@ -849,16 +867,12 @@ impl Interp<'_> {
let multi = s.sources.len() > 1;
let row_bits = if multi { s.w * bpc } else { s.w * n * bpc };
let row = row_bits.div_ceil(8);
let planes: Vec<Vec<u8>> = if multi {
let mut v = vec![];
for src in &s.sources {
v.push(self.source_bytes(src, row * s.h)?);
}
v
} else {
let src = s.sources.first().ok_or(PsError::Ps("undefined", "DataSource".into()))?;
vec![self.source_bytes(src, row * s.h)?]
};
// Several sources are read in turn, a row at a time each (zeros past their ends).
let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? };
let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?;
for p in &mut planes {
p.resize(row * s.h, 0);
}
let max = ((1u32 << bpc.min(16)) - 1) as f64;
let sample = |plane: &[u8], y: usize, i: usize| -> u32 {
let bit = y * row * 8 + i * bpc;
+68 -23
View File
@@ -49,6 +49,12 @@ pub(crate) struct Interp<'a> {
/// Bytes asked for so far (see [`MAX_MEMORY`]).
allocated: usize,
seed: u32,
/// The `save`s not restored yet, oldest first.
saves: Vec<SaveMark>,
/// What dictionary entries were before they changed since the oldest `save` (dictionary,
/// key, old value): `restore` puts them back, as restoring local VM does (PLRM 3rd ed.,
/// §3.7.3 "Save and Restore": composite objects get the values they had at the `save`).
journal: Vec<(DictRef, Key, Option<Obj>)>,
/// Where the error that is unwinding was raised (see [`Fault`]).
pub fault: Option<Fault>,
/// The width a Type 3 glyph procedure gave (`setcachedevice`, `setcharwidth`).
@@ -56,8 +62,8 @@ pub(crate) struct Interp<'a> {
pub g: GState,
pub saved: Vec<GState>,
pub out: Out,
/// The program is an Illustrator file's: its `u` … `U` (written at the top level, whatever
/// its prolog defines them as) are groups.
/// The program is in the legacy Illustrator format: its group operators `u` … `U` (at the top
/// level, whatever its prolog defines them as) are groups (see the module docs of `import`).
pub illustrator: bool,
}
@@ -72,6 +78,13 @@ pub(crate) struct Fault {
/// Most procedure names a [`Fault`] keeps.
const FAULT_PROCS: usize = 3;
/// Where a `save` was made: the graphics states saved and the journal's length then.
#[derive(Clone, Copy, Debug)]
struct SaveMark {
gstates: usize,
journal: usize,
}
fn new_dict() -> DictRef {
Rc::new(RefCell::new(Dict::new()))
}
@@ -143,6 +156,8 @@ impl<'a> Interp<'a> {
ops: 0,
allocated: 0,
seed: 1,
saves: vec![],
journal: vec![],
fault: None,
glyph_width: None,
g,
@@ -157,7 +172,7 @@ impl<'a> Interp<'a> {
loop {
let Some(o) = self.lex.next()? else { return Ok(()) };
let o = self.scanned(o, self.lex.immediate)?;
// An Illustrator group begins before its `u` runs (`Some(true)`) and ends after its `U`
// A group begins before its `u` runs (`Some(true)`) and ends after its `U`
// has (`Some(false)`).
let group = match &o {
Obj::Exec(name) if self.illustrator => match &**name {
@@ -190,6 +205,7 @@ impl<'a> Interp<'a> {
}
self.dicts.clear();
self.stack.clear();
self.journal.clear();
}
// ---------- the operand stack ----------
@@ -879,7 +895,7 @@ impl<'a> Interp<'a> {
}
Obj::Dict(d) => {
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
insert(&d, key, v)?;
self.insert(&d, key, Some(v))?;
true
}
_ => return ps_err("typecheck", ""),
@@ -968,7 +984,7 @@ impl<'a> Interp<'a> {
let v = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
insert(&d, k, v)?;
self.insert(&d, k, Some(v))?;
}
Load => {
let k = self.pop()?;
@@ -981,7 +997,7 @@ impl<'a> Interp<'a> {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let d = self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).or(self.dicts.last()).cloned();
let d = d.ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
insert(&d, k, v)?;
self.insert(&d, k, Some(v))?;
}
Known => {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
@@ -1001,7 +1017,8 @@ impl<'a> Interp<'a> {
}
Undef => {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
self.pop_dict()?.borrow_mut().remove(&k);
let d = self.pop_dict()?;
self.insert(&d, k, None)?;
}
CurrentDict => {
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
@@ -1034,11 +1051,24 @@ impl<'a> Interp<'a> {
}
Save => {
self.gsave()?;
self.push(Obj::Save(self.saved.len()))?;
self.push(Obj::Save(self.saves.len()))?;
self.saves.push(SaveMark { gstates: self.saved.len(), journal: self.journal.len() });
}
Restore => {
let Obj::Save(level) = self.pop()? else { return ps_err("typecheck", "") };
while self.saved.len() >= level && !self.saved.is_empty() {
let Obj::Save(i) = self.pop()? else { return ps_err("typecheck", "") };
// A save restored already (or through an outer one) is no longer valid: §3.7.3,
// `invalidrestore`.
let mark = self.saves.get(i).copied().ok_or(PsError::Ps("invalidrestore", String::new()))?;
self.saves.truncate(i);
// The dictionaries as they were at the save, newest change undone first.
for (d, k, old) in self.journal.drain(mark.journal..).rev() {
let mut d = d.borrow_mut();
match old {
Some(v) => d.insert(k, v),
None => d.remove(&k),
};
}
while self.saved.len() >= mark.gstates && !self.saved.is_empty() {
self.grestore();
}
}
@@ -1083,9 +1113,12 @@ impl<'a> Interp<'a> {
self.push(Obj::Int(1 << 20))?;
}
}
// An empty font cache of a device's sizes, in `cachestatus`'s order (PLRM 3rd ed.,
// chapter 8): bytes used and most, fonts used and most, glyphs used and most, and the
// most bytes one cached glyph may take (positive on a real device: programs divide by it).
CacheStatus => {
for _ in 0..7 {
self.push(Obj::Int(0))?;
for v in [0, 1 << 20, 0, 1000, 0, 10_000, 25_000] {
self.push(Obj::Int(v))?;
}
}
StartJob => {
@@ -1143,7 +1176,9 @@ impl<'a> Interp<'a> {
}
Obj::Dict(d) => {
let from = self.pop_dict()?.borrow().clone();
d.borrow_mut().extend(from);
for (k, v) in from {
self.insert(&d, k, Some(v))?;
}
self.push(Obj::Dict(d))?;
}
_ => return ps_err("typecheck", "copy"),
@@ -1219,6 +1254,26 @@ impl<'a> Interp<'a> {
}
}
/// Put `v` under `k` in `d` (`None`: remove it), within [`MAX_ALLOC`] entries; while a `save`
/// is open the old value is kept for `restore`.
fn insert(&mut self, d: &DictRef, k: Key, v: Option<Obj>) -> Res {
let old = {
let mut dm = d.borrow_mut();
if v.is_some() && dm.len() >= MAX_ALLOC && !dm.contains_key(&k) {
return Err(PsError::Limit("a dictionary grew too large"));
}
match v {
Some(v) => dm.insert(k.clone(), v),
None => dm.remove(&k),
}
};
if !self.saves.is_empty() {
self.alloc(2 * std::mem::size_of::<Obj>())?;
self.journal.push((d.clone(), k, old));
}
Ok(())
}
// ---------- resources ----------
/// The resources of `category`.
@@ -1293,16 +1348,6 @@ impl<'a> Interp<'a> {
}
}
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
fn insert(d: &DictRef, k: Key, v: Obj) -> Res {
let mut d = d.borrow_mut();
if d.len() >= MAX_ALLOC && !d.contains_key(&k) {
return Err(PsError::Limit("a dictionary grew too large"));
}
d.insert(k, v);
Ok(())
}
/// An array or string index.
fn index(k: &Obj) -> Res<usize> {
match *k {
+9 -4
View File
@@ -11,9 +11,11 @@
//! - `text`: fonts by name and type as point type; Type 3 fonts' glyphs drawn by their procedures.
//! - `preview`: the previews a file carries, for when its PostScript can't be read.
//!
//! Clipped art comes in as clipping groups. In an Illustrator file (Illustrator 3–8, written with
//! the prolog that defines its operators), the groups it writes (`u` … `U`, nested) come in as
//! groups.
//! Clipped art comes in as clipping groups. In a file in the legacy Illustrator format (versions
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
//! as groups. The format's published specification documents its header comments (`%AI…`,
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423.
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
@@ -39,6 +41,8 @@ mod tests_fontnames;
mod tests_generators;
#[cfg(test)]
mod tests_illustrator;
#[cfg(test)]
mod tests_images;
use std::sync::Arc;
@@ -73,7 +77,8 @@ struct Dsc {
/// `llx lly urx ury` in PostScript's default space.
bbox: Option<[f64; 4]>,
pages: Option<u32>,
/// Written by Illustrator (or in its format): `%AI…` comments, or Illustrator the creator.
/// In the legacy Illustrator format: its header has `%AI…` comments or names the app as the
/// creator (see the module docs for the specification).
illustrator: bool,
}
+87 -119
View File
@@ -42,7 +42,7 @@ fn a85_flate(data: &[u8]) -> String {
}
/// Pixel `(x, y)` (straight RGBA) of the first image in `d`.
fn pixel(d: &Document, x: u32, y: u32) -> [u8; 4] {
pub(super) fn pixel(d: &Document, x: u32, y: u32) -> [u8; 4] {
let im = all(d)
.into_iter()
.find_map(|n| match &n.kind {
@@ -592,105 +592,52 @@ showpage"##;
assert!(near(bounds(&meshes[1]), Rect::new(150.0, 90.0, 190.0, 140.0)), "{:?}", bounds(&meshes[1]));
}
/// Illustrator's EPS (CS and later, through 2026): procsets that define helpers with
/// `bdf`/`ndf`/`ddf`, probe the level and version (`{version cvr} stopped`), switch VM with
/// `currentglobal`/`setglobal`, save and restore the whole graphics state through
/// `currentcolorrendering`, `rootfont`, the colour transfers, half-tone and flatness, set up
/// categories and keep fonts by VM (`gcheck`), while the art is drawn with procedures of their own.
/// A procset written as the PostScript Language Reference (3rd ed.) describes the Level 2 and 3
/// facilities programs probe before drawing: a resource category made from `Generic` and
/// resources defined and found in it (§3.9), local and global VM (`currentglobal`, `setglobal`,
/// `gcheck`, §3.7.2), `internaldict` (§8.2), a halftone dictionary (§7.4), the font cache
/// (`cachestatus`), `rootfont` and the colour rendering dictionary (§7.1), each guarded or
/// probed through `stopped` (§3.10), saving the state they change and drawing with procedures of
/// their own.
#[test]
fn illustrator_procsets_read_as_vectors() {
fn level_2_and_3_probes_read_as_vectors() {
let program = r##"%%BeginProlog
%%BeginResource: procset Adobe_AGM_Utils 1.0 0
systemdict /setpacking known { currentpacking true setpacking } if
userdict /Adobe_AGM_Utils 75 dict dup begin put
/bdf { bind def } bind def
/nd { null def } bdf
/xdf { exch def } bdf
/ddf { put } bdf
/xddf { 3 -1 roll put } bdf
/ndf { exch dup where { pop pop pop } { xdf } ifelse } def
/gx { get exec } bdf
/ps_level /languagelevel where { pop systemdict /languagelevel gx } { 1 } ifelse def
/level2 ps_level 2 ge def
/level3 ps_level 3 ge def
/ps_version { version cvr } stopped { -1 } if def
/set_gvm { currentglobal exch setglobal } bdf
/reset_gvm { setglobal } bdf
/makereadonlyarray { /packedarray where { pop packedarray } { array astore readonly } ifelse } bdf
/GSTATE 22 dict def
/get_gstate {
GSTATE begin
/clr_spc currentcolorspace def
mark currentcolor counttomark array astore /clr_comps xdf pop
/fnt rootfont def
/lw currentlinewidth def /lc currentlinecap def /lj currentlinejoin def /ml currentmiterlimit def
currentdash /dsh_o xdf /dsh_a xdf
/sa currentstrokeadjust def
/crd currentcolorrendering def
/op currentoverprint def
/bg currentblackgeneration cvlit def
/ucr currentundercolorremoval cvlit def
currentcolortransfer cvlit /gy_x xdf cvlit /b_x xdf cvlit /g_x xdf cvlit /r_x xdf
/ht currenthalftone def
/flt currentflat def
end
} def
/set_gstate {
GSTATE begin
clr_spc setcolorspace clr_comps aload pop setcolor
fnt setfont lw setlinewidth lc setlinecap lj setlinejoin ml setmiterlimit dsh_a dsh_o setdash
sa setstrokeadjust crd setcolorrendering op setoverprint
bg cvx setblackgeneration ucr cvx setundercolorremoval
r_x cvx g_x cvx b_x cvx gy_x cvx setcolortransfer
ht /HalftoneType get dup 9 eq exch 100 eq or { ht sethalftone } if
flt setflat
end
} def
/ct_tst { 1183615869 internaldict pop } stopped pop
currentdict readonly pop
/ExampleKit 40 dict def ExampleKit begin
/defp { bind def } bind def
% A category of our own, implemented by a copy of Generic's dictionary (§3.9.2).
/Generic /Category findresource dup length dict copy /ExampleShapes exch /Category defineresource pop
/Card << /W 80 /H 50 >> /ExampleShapes defineresource pop
% VM: a dictionary made in global VM, then the VM mode put back.
/wasglobal currentglobal def true setglobal /Shared 4 dict def wasglobal setglobal
/vm Shared gcheck { (global) } { (local) } ifelse def
% Probes that fail on some interpreters, inside stopped.
/hasinternal { 1183615869 internaldict } stopped { false } { pop true } ifelse def
/screen << /HalftoneType 1 /Frequency 85 /Angle 15 /SpotFunction { 180 mul cos exch 180 mul cos add 2 div } >> def
/oldscreen currenthalftone def
/rendering currentcolorrendering def
/cache [ cachestatus ] def
/frame { /h exch def /w exch def moveto w 0 rlineto 0 h rlineto w neg 0 rlineto closepath } defp
end
systemdict /setpacking known { setpacking } if
%%EndResource
%%BeginResource: procset Adobe_AGM_Core 2.0 0
userdict /Adobe_AGM_Core 200 dict dup begin put
/AGMCORE_str256 256 string def
/AGMCORE_deviceDPI 72 0 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt def
/AGMCORE_distilling /product where { pop systemdict /setdistillerparams known product (Adobe PostScript Parser) ne and } { false } ifelse def
/AGMCORE_producing_seps currentpagedevice /Separations 2 copy known { get } { pop pop false } ifelse def
/AGMCORE_page_size currentpagedevice /PageSize get def
/Generic /Category findresource dup length dict copy /Category defineresource pop
/AGMCORE_mo { moveto } def /AGMCORE_li { lineto } def /AGMCORE_cv { curveto } def /AGMCORE_cp { closepath } def
/AGMCORE_rgb { setrgbcolor } def /AGMCORE_cmyk { setcmykcolor } def
/doc_setup { Adobe_AGM_Utils begin get_gstate end } def
/page_setup { Adobe_AGM_Utils begin set_gstate end } def
end
%%EndResource
%%BeginResource: procset Adobe_CoolType_Core 2.31 0
userdict /CT_Core 20 dict dup begin put
/GVMFonts 10 dict def /LVMFonts 10 dict def
/VMDictPut { dup gcheck { GVMFonts } { LVMFonts } ifelse 3 1 roll put } bind def
/copyfont { currentglobal exch dup gcheck setglobal dup length dict copy exch setglobal } bind def
end
%%EndResource
%%EndProlog
%%BeginSetup
Adobe_AGM_Core /doc_setup get exec
CT_Core begin /Body /Helvetica findfont copyfont VMDictPut end
ExampleKit begin
screen sethalftone
/Helvetica 12 selectfont
/face rootfont /FontName get def
%%EndSetup
%%Page: 1 1
%%BeginPageSetup
Adobe_AGM_Core /page_setup get exec
%%EndPageSetup
Adobe_AGM_Core begin
/mo { AGMCORE_mo } def /li { AGMCORE_li } def /cv { AGMCORE_cv } def /cp { AGMCORE_cp } def
0 0.94 0.94 0.12 AGMCORE_cmyk
10 140 mo 90 140 li 90 90 li 10 90 li cp fill
0.2 0.4 0.8 AGMCORE_rgb
100 20 mo 150 80 180 0 190 60 cv 2 setlinewidth stroke
0 setgray CT_Core /LVMFonts get /Body get 12 scalefont setfont 10 30 moveto (Art) show
gsave
0 0.94 0.94 0.12 setcmykcolor
10 90 /Card /ExampleShapes findresource dup /W get exch /H get frame fill
0.2 0.4 0.8 setrgbcolor 2 setlinewidth 100 20 moveto 150 80 180 0 190 60 curveto stroke
grestore
% Each probe answered as the reference says: draw only when it did.
vm length 5 eq hasinternal and rendering type /dicttype eq and cache length 7 eq and face /Helvetica eq and
{ 0 setgray 10 30 moveto (Art) show } if
oldscreen sethalftone
end
showpage"##;
let r = open("Adobe Illustrator(R) 30.0", program);
let r = open("Example Generator 1.0", program);
let d = &r.document;
let rect = all(d).into_iter().find(|n| fill(n).and_then(Paint::color) == Some(Color::cmyk(0.0, 0.94, 0.94, 0.12))).unwrap();
assert!(near(bounds(&rect), Rect::new(10.0, 10.0, 90.0, 60.0)), "{:?}", bounds(&rect));
@@ -950,44 +897,65 @@ fn hostile_patterns_glyphs_and_shadings_end() {
assert_eq!(objects(&r.document).len(), 1);
}
/// Illustrator 8 EPS (what stock art often comes as, #474): its gradient procset builds shading
/// dictionaries with `bd` … `ed`, where `ed` is an executable string (`(>>) cvx`, or a Level 1
/// dictionary builder), and its `discard` runs procedures it `load`s by integer keys.
/// Executable strings and keys other than names (#474): `cvx` makes a string executable, and
/// running it runs its text (PLRM 3rd ed. §3.3.1, `cvx`, `exec`); `load` takes any key (§8.2).
/// A program closes its shading dictionaries with an executable string (`>>` at Level 2 and up,
/// else a dictionary built with `counttomark`), and runs procedures it keeps under integer keys
/// through `stopped`.
#[test]
fn illustrator_8_gradients_and_executable_strings() {
fn executable_strings_and_integer_keys() {
let program = r##"%%BeginProlog
userdict /AGM_Gradient 20 dict dup begin put
/AGM_Gradient_private 201 dict def
/initialize {
AGM_Gradient begin AGM_Gradient_private begin
/bd systemdict /mark get def
/ed /languagelevel where { pop languagelevel 2 ge } { false } ifelse
{ (>>) } { (counttomark 2 idiv dup dict begin {def} repeat pop currentdict end) } ifelse cvx def
/ed1 (counttomark 2 idiv dup dict begin {def} repeat pop currentdict end) cvx def
end end
} def
userdict /discardDict 4 dict dup begin put
0 { 0 1 0 setrgbcolor } def
2 { 0 0 1 setrgbcolor } def
/ShadeKit 12 dict def ShadeKit begin
% A Level 1 way to make a dictionary of the pairs above a mark.
/pairs (counttomark 2 idiv dict begin { counttomark 0 eq { exit } if def } loop pop currentdict end) cvx def
/close /languagelevel where { pop languagelevel 2 ge } { false } ifelse { (>>) } { (pairs) } ifelse cvx def
/open /mark load def
/actions 3 dict def actions begin 0 { 0 1 0 setrgbcolor } def 1 { 1 0 0 setrgbcolor } def end
/act { actions 1 index known { actions begin load end stopped pop } { pop } ifelse } bind def
end
/gt38? false def
/discard { discardDict begin /endString exch def gt38? { 2 add } if load stopped pop end } bind def
%%EndProlog
initialize
AGM_Gradient begin AGM_Gradient_private begin
bd /ShadingType 3 /ColorSpace /DeviceRGB
/Function bd /FunctionType 2 /Domain [0 1] /C0 [1 1 0] /C1 [0 0.5 0] /N 1 ed
/Extend [true true] /Coords [50 50 0 50 50 40] ed
ShadeKit begin
open /ShadingType 3 /ColorSpace /DeviceRGB
/Function open /FunctionType 2 /Domain [0 1] /C0 [1 1 0] /C1 [0 0.5 0] /N 1 close
/Extend [true true] /Coords [50 50 0 50 50 40] close
gsave 10 10 80 80 rectclip shfill grestore
bd /Width 3 ed1 /Width get 3 eq { 0 (%AI5_EndPalette) discard 10 10 20 20 rectfill } if
end end
mark /Width 3 pairs /Width get 3 eq { 0 act 10 10 20 20 rectfill } if
2 act
end
(x) cvx xcheck (x) cvx cvlit xcheck not and { 50 50 10 10 rectfill } if
showpage"##;
let r = open("Adobe Illustrator(R) 8.0", program);
let r = open("Example Generator 1.0", program);
let d = &r.document;
let Some(Paint::Gradient(g)) = all(d).into_iter().find_map(|n| fill(&n).cloned()) else { panic!("no gradient") };
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Radial);
// `discard` ran the procedure under key 0 (green), not the one under 2.
// `act` ran the procedure under key 0 (green); there is none under key 2.
assert!(all(d).iter().any(|n| fill(n).and_then(Paint::color) == Some(Color::rgb(0.0, 1.0, 0.0))));
assert_eq!(objects(d).len(), 3);
}
/// `restore` puts local VM back as it was at the `save` (PLRM 3rd ed., §3.7.3 "Save and
/// Restore"): dictionaries get back the entries they had, whatever changed them. A program that
/// keeps a count of the saves it has open in a dictionary, and refuses to go past a depth, reads
/// to its end only if each `restore` puts the count back (part of #505: files with many images,
/// each drawn between `save` and `restore`).
#[test]
fn restore_puts_dictionaries_back() {
let r = read(
"/Depth 2 dict def Depth /open 0 put \
/enter { save Depth /open get 1 add dup 8 gt { rangecheck } if Depth exch /open exch put } def \
1 1 100 { pop enter 0 0 1 1 rectfill restore } for Depth /open get 0 eq { 10 10 5 5 rectfill } if",
);
clean(&r);
assert_eq!(objects(&r.document).len(), 101);
// `def`, `store`, `put`, `undef` and dictionary `copy` are undone, newest first.
super::tests::check(
"/a 1 def /d 2 dict def d /k 1 put save /a 2 def /b 3 def d /k 2 put d /k undef 1 dict dup /z 9 put d copy pop \
/a 4 store restore a 1 eq /b where not and d /k get 1 eq and d /z known not and",
);
// A save restored (itself, or through an outer one) is no longer valid: `invalidrestore`.
super::tests::check("save dup restore { restore } stopped");
super::tests::check("save save exch restore { restore } stopped");
// `cachestatus` gives a device's cache sizes; its last, the most bytes one cached glyph may
// take, is positive (chapter 8), and programs divide by it.
super::tests::check("cachestatus 7 1 roll 6 { pop } repeat 0 gt");
}
@@ -20,6 +20,9 @@ fn ai(header: &str, ugroup: &str, body: &str) -> Vec<u8> {
.into_bytes()
}
/// A legacy Illustrator format header: the reader takes `u` … `U` as groups only in files whose
/// header says they are in that format (`%AI…` comments or the creator, which the format's
/// published specification documents; see the `import` module docs).
const AI8: &str = "%%Creator: Adobe Illustrator(R) 8.0\n%AI5_FileFormat 4.0\n";
/// A triangle filled at `x`.
+130
View File
@@ -0,0 +1,130 @@
//! Image data read as the PostScript Language Reference (3rd ed.) defines it: several data sources
//! read in turn (§4.10 "Images", `image` and `colorimage` in chapter 8), rows padded to a whole
//! byte at every bit depth and odd widths (§4.10), and Flate predictors undone row by row (§3.13,
//! `FlateDecode` parameters) (#498).
use super::tests::read;
use super::tests_generators::pixel;
/// Each pixel of the first image, row by row.
fn pixels(r: &crate::Imported, w: u32, h: u32) -> Vec<[u8; 4]> {
assert!(!r.preview && r.warnings.is_empty(), "{:?}", r.warnings);
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| pixel(&r.document, x, y)).collect()
}
/// A CMYK image whose four data sources are procedures reading one file, a row of one ink each:
/// the image operator calls them in turn, so each gets its own rows. Read one after the other,
/// the cyan source took the first four rows of all four inks and the image came out in
/// horizontal bands (#498).
#[test]
fn procedures_reading_one_file_are_read_in_turn() {
// 3 × 2 pixels; rows: C M Y K of row 0, then of row 1.
let rows = ["ff0000", "00ff00", "000000", "000000", "000000", "000000", "ffff00", "0000ff"];
let r = read(&format!(
"/DeviceCMYK setcolorspace 30 20 scale /f currentfile /ASCIIHexDecode filter def \
<< /ImageType 1 /Width 3 /Height 2 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [3 0 0 -2 0 2] \
/MultipleDataSources true /DataSource [ {{f 3 string readstring pop}} {{f 3 string readstring pop}} \
{{f 3 string readstring pop}} {{f 3 string readstring pop}} ] >> image\n{}>",
rows.concat()
));
let cyan = [0, 174, 239, 255];
let magenta = [236, 0, 140, 255];
let yellow = [255, 242, 0, 255];
let black = [35, 31, 32, 255];
let white = [255, 255, 255, 255];
let px = pixels(&r, 3, 2);
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 40);
let want = [cyan, magenta, white, yellow, yellow, black];
assert!(px.iter().zip(want).all(|(a, b)| near(*a, b)), "{px:?}");
// The operand form: `{r} {g} {b} true 3 colorimage`, procedures reading one file.
let r = read(
"30 20 scale /f currentfile /ASCIIHexDecode filter def \
3 2 8 [3 0 0 -2 0 2] {f 3 string readstring pop} {f 3 string readstring pop} {f 3 string readstring pop} true 3 colorimage\n\
ff0000 00ff00 0000ff 102030 405060 708090>",
);
assert_eq!(pixels(&r, 3, 2), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [16, 64, 112, 255], [32, 80, 128, 255], [48, 96, 144, 255]]);
}
/// Separate strings and files as the sources: each is read for its own plane, whatever the row.
#[test]
fn separate_sources_keep_their_planes() {
let r = read("30 20 scale 3 2 8 [3 0 0 -2 0 2] <ff0000102030> <00ff00405060> <0000ff708090> true 3 colorimage");
assert_eq!(pixels(&r, 3, 2), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [16, 64, 112, 255], [32, 80, 128, 255], [48, 96, 144, 255]]);
}
/// Rows of an odd width at 1, 2, 4, 12 and 16 bits a sample start at a byte (their last byte
/// padded), in one source and in several.
#[test]
fn rows_of_every_depth_start_at_a_byte() {
// 3 pixels a row, two rows: 0, max, mid / max, 0, 0 (grey), each row padded.
let cases = [
(1, "a0 80"), // 101(00000) 100(00000)
(2, "cc c0"), // 11 00 11 (00), 11 00 00 (00)
(4, "f0 80 f0 00"), // f 0 8 (0), f 0 0 (0)
(12, "fff0008000 fff0000000"), // fff 000 800 (0), fff 000 000 (0)
(16, "ffff00008000 ffff00000000"),
];
for (bpc, data) in cases {
let data = data.replace(' ', "");
let r = read(&format!("30 20 scale 3 2 {bpc} [3 0 0 -2 0 2] <{data}> image"));
let px: Vec<u8> = pixels(&r, 3, 2).iter().map(|p| p[0]).collect();
let want: &[u8] = match bpc {
1 => &[255, 0, 255, 255, 0, 0],
2 => &[255, 0, 255, 255, 0, 0],
4 => &[255, 0, 136, 255, 0, 0],
12 => &[255, 0, 128, 255, 0, 0],
_ => &[255, 0, 128, 255, 0, 0],
};
assert_eq!(px, want, "{bpc} bits");
}
// Several 4-bit sources, odd width: each plane's rows padded on their own.
let r = read("30 20 scale 3 2 4 [3 0 0 -2 0 2] <f0f000f0> <0f00f000> <00f00ff0> true 3 colorimage");
let (on, off) = (255, 0);
let want = [[on, off, off], [off, on, off], [on, off, on], [off, on, off], [off, off, on], [on, off, on]];
assert_eq!(pixels(&r, 3, 2), want.map(|[r, g, b]| [r, g, b, 255]));
}
/// A PNG-predicted Flate image of an odd width: every row filter (None, Sub, Up, Average, Paeth)
/// undone on its own row.
#[test]
fn predicted_rows_of_an_odd_width() {
// 3 RGB pixels a row, five rows; each row predicted with its own filter.
let raw: Vec<[u8; 9]> = (0..5u8).map(|y| std::array::from_fn(|i| (i as u8).wrapping_mul(29).wrapping_add(y.wrapping_mul(53)))).collect();
let mut encoded = vec![];
let mut prev = [0u8; 9];
for (kind, row) in raw.iter().enumerate() {
encoded.push(kind as u8);
for i in 0..9 {
let a = if i >= 3 { row[i - 3] } else { 0 };
let b = prev[i];
let c = if i >= 3 { prev[i - 3] } else { 0 };
let pred = match kind {
1 => a,
2 => b,
3 => ((u16::from(a) + u16::from(b)) / 2) as u8,
4 => {
let p = i16::from(a) + i16::from(b) - i16::from(c);
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
_ => 0,
};
encoded.push(row[i].wrapping_sub(pred));
}
prev = *row;
}
let r = read(&format!(
"/DeviceRGB setcolorspace 30 50 scale << /ImageType 1 /Width 3 /Height 5 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /ImageMatrix [3 0 0 -5 0 5] \
/DataSource currentfile /ASCII85Decode filter << /Predictor 15 /Colors 3 /Columns 3 >> /FlateDecode filter >> image\n{}",
crate::ps::ascii85(&crate::ps::deflate(&encoded))
));
let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect();
assert_eq!(pixels(&r, 3, 5), want);
}
+1 -1
View File
@@ -291,7 +291,7 @@ impl<'a> Scene<'a> {
}
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more