Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 13d5645052 Fix #479: Add/Delete Anchor Point tool icons drawn from the Pen tool's
The two tools had hand-drawn nibs that didn't match the Pen tool's icon and read as distorted
next to it. They now use Lucide's pen-tool (the Pen tool's own icon) with a plus or minus badge,
so the Pen group reads as one family; the Remove Anchor Points buttons pick up the minus one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:17:59 +07:00
BFlat 6f455f9616 Merge pull request #476 from storytold/fix/issue-472
Part of #472: Escape leaves Presentation Mode; dropped documents open as tabs; .ai off-artboard note
2026-10-08 18:06:12 +07:00
bflatasticandClaude Opus 5.5 e98a880e75 Part of #472: Escape leaves Presentation Mode; dropped documents open as tabs; .ai off-artboard note
Presentation Mode hides the menus and panels, had no Escape and was saved with the
preferences, so it came back on every restart with no way out. Escape now leaves it (as in
Illustrator), F from it goes back to Normal Screen Mode, and the screen mode is no longer
saved: the app always starts in Normal Screen Mode, and preferences saved in Presentation
Mode by older versions open normally. ui.inspect reports `screenMode` on its own.

Desktop file drags carry no pointer position or modifiers (winit 0.30 on Windows, macOS
and X11), so every drop on a window with a document open was taken for the middle of the
canvas and placed. Where the platform doesn't say, a dropped document (native, SVG, PDF,
.ai, EPS, DXF, EMF/WMF) now opens as a tab of its own and a picture or text is placed in
the middle of the view; pictures dropped together with documents are placed first, so the
last document opened is shown. The web, whose drag events give the position, keeps
Illustrator's choice: placed on the canvas, opened off it (the tab bar).

Illustrator's PDF-compatible part leaves out art outside the artboards (it lives in the
private data alone, which isn't read), so it can't open; importing a file with that
private data now says so in a note. Off-page art a PDF does draw already opens on the
pasteboard; a test keeps it so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:01:12 +07:00
BFlat 44e4e63f22 Merge pull request #463 from imachus/pr/ja-burasagari
Type: burasagari — a Japanese comma or full stop ending a line may hang outside it
2026-10-08 17:42:42 +07:00
bflatasticandClaude Opus 5.5 10a87311d3 Type: burasagari labels Regular/Force, full-width commas and full stops hang, MCP docs (review of #463)
- The Paragraph panel menu's Burasagari submenu reads None / Regular / Force, as the reference
  app's help names them (`text.setFormat` keeps "none"/"standard"/"forced", its scripting names);
  Reset Panel goes back to new type's Regular.
- The full-width ,.and half-width 、。 hang too; Latin commas and full stops never do, so Latin
  text keeps its breaks and composer with new type's default (tested). The spaces after a hanging
  mark stay on its line.
- docs/mcp.md: a Burasagari section. ROADMAP: wording, Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:05 +07:00
imachusandClaude Opus 5.5 eb38894759 Type: new type takes Standard burasagari, as in Illustrator; Latin paragraphs keep the every-line composer
- New type (the Type tools, `text.create`, area type and type on a path) takes Standard
  burasagari, Illustrator's default (observed in its Paragraph panel menu). Documents from before
  it and imported text keep None.
- Only a paragraph with a Japanese comma or full stop is composed line by line, so Latin text keeps
  the every-line composer with the new default (tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:05 +07:00
imachusandClaude Opus 5.5 08e7a4f713 Type: burasagari — a Japanese comma or full stop ending a line may hang outside it
Paragraph panel menu › Burasagari › None / Standard / Forced (with the East Asian options) and
`text.setFormat {burasagari}`:

- None: a comma or full stop that doesn't fit goes to the next line with the character before it.
- Standard: one that doesn't fit hangs outside the line; one that fits stays inside.
- Forced: one ending an area type line always hangs, and the rest of the line fills the measure.

Only 、 and 。 hang (closing brackets don't), in the width their mojikumi gives them. Horizontal
and vertical. Saved only when not None; new type keeps None. Paragraphs with burasagari are
composed line by line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:05 +07:00
BFlat 58330fba83 Merge pull request #473 from storytold/feat/pen-anchor-editing
Pen and anchors: Alt edits one handle, cut and convert selected anchors from the Control bar
2026-10-08 17:26:26 +07:00
BFlat 47209d3724 Merge pull request #471 from storytold/feat/control-bar-images
Control bar: image controls (Image Trace, Embed, Crop, Mask) when an image is selected
2026-10-08 17:26:08 +07:00
bflatasticandClaude Opus 5.5 253249717b Pen and anchors: Alt edits one handle, cut and convert selected anchors from the Control bar
Discord feedback: Alt with the Pen didn't move a direction handle on its own, and there was no
way to break a path at an anchor and pull the ends apart.

- Pen: Alt held over a handle end or an anchor of a selected path (the one being drawn too) hands
  the gesture to the Anchor Point tool, with the convert cursor: dragging a handle moves it alone,
  clicking a smooth anchor makes it a corner, dragging an anchor pulls out symmetric handles.
  Drawing goes on afterwards. Handles hidden by a partial anchor selection aren't grabbed.
- path.cutAtAnchors cuts at the direct-selected anchors in one undo step: a closed path opens
  there, an open one becomes one path per piece, and one anchor of each cut stays selected so a
  drag pulls the path apart. It answers {ids} and refuses (no undo step) when only open ends are
  selected. path.convertAnchors checks `to`, skips stale anchor refs instead of indexing past
  them, and keeps a smooth anchor's handles when converting to smooth.
- SubPath::cut_at and SubPath::smooth_anchor (geom) now back path.split, path.cutAtAnchors,
  path.convertAnchor and path.convertAnchors, replacing three copies of that logic.
- Control bar and Properties panel: "Convert:" corner/smooth and "Anchors:" remove, connect
  (Join), cut buttons when anchors are selected; Cut Path on the task bar and the context menu.
  New original icon dc-anchor-smooth.svg (ASSETS.md row).
- ja, zh-hans, zh-hant and es strings (cs for the context menu item), es @msg rows; docs/mcp.md
  section; ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:16:12 +07:00
bflatasticandClaude Opus 5.5 d6f8cc879f Control bar: image controls (Image Trace, Embed, Crop, Mask) when an image is selected
Discord feedback: Image Trace and the other image options were hidden when an image was
selected; copy, paste, Image Trace should work from the Control bar as in Illustrator.

- Control bar, one image selected: Linked File/Embedded, file name, colour mode and PPI, then
  Embed (+ Edit Original) or Unembed…, an Image Trace split button (click: Default preset;
  arrow: trace with any preset), Mask and Crop Image, in place of Fill and Stroke.
- Control bar, one Image Trace object selected: "Image Tracing", its Preset (choosing another
  traces it again), the Image Trace panel and Expand.
- Properties panel: Image Trace (split), Crop Image and Mask in Quick Actions for an image; an
  Image Trace section (Preset, Expand, Release) for an Image Trace object. Embed/Unembed buttons
  are shared with the Control bar (place::link_buttons).
- New engine command object.maskImage ("Mask"): clips the selected image with a clipping path
  on its outline (rotated with it) and selects that path so its handles crop; one undo step.
- widgets::split_button (flat button with a menu arrow).
- Translations for the new strings (es, ja, zh-hans, zh-hant; "Mask" also pt-br, cs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:15:48 +07:00
BFlat 87ba4f6454 Merge pull request #465 from switch87/desktop-log-file
Desktop: write log records to standard error and a rotating log file
2026-10-08 17:15:25 +07:00
BFlat aae7179b72 Merge pull request #470 from storytold/feat/ai-import-groups
AI import: groups and nested groups survive
2026-10-08 17:15:11 +07:00
bflatasticandClaude Opus 5.5 20ed00784f Desktop: report an unusable log folder through the logger, ROADMAP (review of #465)
The "no log file" message went through eprintln!, which panics when
standard error is a broken pipe; log::warn! reaches standard error the
same way (the file sink has already given up) and never panics.
ROADMAP: the log file in "Shipped so far" and "Last updated".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:07:24 +07:00
Gert Pellin ba9edcdc7f Desktop: write log records to standard error and a rotating log file
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: the graphics adapter in use, a lost graphics
device, the engine guard's report of a recovered panic. A launch from a
desktop menu has no terminal either, so there was nothing to attach to a
bug report.

apps/vectorcraft/src/logging.rs (no new dependency):
- standard error plus logs/vectorcraft.log next to the preferences
  (~/.config/vectorcraft/logs, or under $XDG_CONFIG_HOME); each launch
  rotates the previous log to vectorcraft.1.log and .2.log, so the log of
  a run that crashed survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for vectorcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink
- runs with VECTORCRAFT_NO_PREFS (agents' test runs) log to standard
  error only, so they don't rotate away the user's own logs

The logger is installed first in main and the file is opened after
argument parsing (--version leaves no file). Log messages of this crate
drop their "vectorcraft: " prefix, which the record's target now shows.
vectorcraft-cli keeps its own MCP logger (vectorcraft_mcp::logging).
Documented in docs/development.md (Logs, Environment variables).
2026-10-08 17:07:08 +07:00
bflatasticandClaude Opus 5.5 0bdce8c633 AI import: groups and nested groups survive
Legacy PostScript .ai files (Illustrator 3-8, and their EPS) open through
the EPS interpreter, which only recorded what was drawn: every group came
in flattened. In a file in Illustrator's format (its %AI comments, or
Illustrator named as the creator) the `u` ... `U` it writes at the top
level now open and close groups, whatever its prolog defines them as, so
groups come in nested as they were, between and inside clipping groups.

clipnest's Clip carries an optional region: without one it is a plain
group, nested in the same chain as the clips (ids given in opening order,
so a chain of clips and groups sorts by id).

Clips nested without end used to nest clipping groups past the stack
(a stack overflow in EPS and EMF/WMF import): clips and groups now nest
at most 128 deep with a warning, and each object's chain counts against
the EPS reader's drawing budget.

PDF-compatible .ai (Illustrator 9+) can't get the same: its PDF content
draws each object on its own and doesn't mark plain groups (only layers,
clipping groups and groups with opacity, blending or a mask, which
already come back); the groups live only in the private data, which is
out of scope. Documented in ROADMAP and docs/mcp.md.

Addresses Discord feedback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:06:57 +07:00
BFlat d66a8b6127 Merge pull request #468 from storytold/feat/type-conversion-widget
Type: the point/area type widget converts between them; area type made by dragging resizes
2026-10-08 17:02:58 +07:00
bflatasticandClaude Opus 5.5 940285dd9e Type: the point/area type widget converts between them; area type made by dragging resizes
Discord feedback: type in a frame dragged with the Type tool could not be resized or switched
between area and point type the way the reference app allows.

- The Selection tool shows the type widget beside a single selected point or area type object:
  a small circle outside the middle of its bounding box's right side, hollow on point type and
  filled on area type (vectorcraft_tools::typewidget, drawn by the canvas from the same geometry).
  Hovering it shows the arrow with a type badge; its clicks neither drag nor deselect, and a
  double-click runs type.convertToAreaType / type.convertToPointType (the Type menu's commands),
  keeping the text and its styles in place, one undo step, reachable over MCP.
- Resizing area type made by dragging already works on main (#252): the bounding box resizes the
  type area and the text reflows. A regression test now covers the reported flow.
- docs/mcp.md and ROADMAP.md describe the widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:54:12 +07:00
BFlat 852848f4ed Merge pull request #467 from storytold/feat/properties-fill-stroke-shortcuts
Properties: Fill and Stroke; Shift+F7 opens Align; Alt+wheel zooms
2026-10-08 16:53:31 +07:00
bflatasticandClaude Opus 5.5 c5b5dbb6ad Properties: Fill and Stroke; Shift+F7 opens Align; Alt+wheel zooms
Discord feedback.

- Properties panel: the Appearance section's Fill and Stroke rows now use the
  Control bar's widgets: the chips with their chevron and swatch/mixer
  popover, the underlined Stroke link that opens the Stroke panel as a
  popover (stroke::link, shared with the Control bar) and the weight spinner
  with its presets (stroke::weight_field, which also follows the Type tool's
  selected characters). With nothing selected the Document view shows them
  too, as the Control bar does: they set up the next object drawn.
- Window menu panel keys as in Illustrator: Brushes F5, Layers F7, Align
  Shift+F7, Transform Shift+F8, Info Cmd+F8, Pathfinder Cmd+Shift+F9,
  Symbols Cmd+Shift+F11, Character Cmd+T, Paragraph Cmd+Alt+T, Tabs
  Cmd+Shift+T, OpenType Cmd+Alt+Shift+T (beside the existing Color, Color
  Guide, Appearance, Graphic Styles, Stroke, Gradient, Transparency and
  Attributes keys). The dock's tabs (Properties, Layers, Libraries) join the
  panel shortcut table and the Keyboard Shortcuts editor through
  state::all_panels, which window.panel's name lookup and the catalog's
  label list use too.
- Alt-wheel (Option on the Mac) zooms about the pointer whatever General >
  Zoom with Mouse Wheel says: egui spreads a notch over a few frames, and the
  rest of an Alt turn scrolled once Alt was up (also over the control
  channel's ui.wheel); the turn's own modifiers now decide. Pinch zoom is
  covered by the test too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:46:34 +07:00
BFlat a9b28cc22a Merge pull request #464 from storytold/revert/gentoo-readme
README and contributors: drop the Gentoo overlay section
2026-10-08 16:41:43 +07:00
bflatasticandClaude Opus 5.5 bde3a222bb README and contributors: drop the Gentoo overlay section and its credit entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:41:00 +07:00
BFlat 752a67e16c Merge pull request #462 from switch87/readme-gentoo
README: install on Gentoo from the ::snakebyte overlay
2026-10-08 16:31:40 +07:00
Gert Pellin 746d2a6b07 Contributors: credit switch87 as Gert Pellin 2026-10-08 16:30:04 +07:00
Gert Pellin 39a2338b2f README: install on Gentoo from the ::snakebyte overlay 2026-10-08 16:30:04 +07:00
BFlat 2a03e9b87f Merge pull request #460 from storytold/fix/issue-457
Fix #457: paste screenshots from the clipboard on macOS, Windows and the web
2026-10-08 16:19:37 +07:00
bflatasticandClaude Opus 5.5 8f771faa9e Fix #457: paste screenshots from the clipboard on macOS, Windows and the web
A screenshot on the clipboard didn't paste everywhere:
- macOS: the Paste menu item (which takes Cmd+V there) was enabled for text
  only, so with nothing copied in the app Cmd+V did nothing; and arboard
  reads the pasteboard's TIFF only. The pasteboard's types now enable Paste,
  and its PNG, else TIFF, pastes as it is.
- Windows: bitmaps came through CF_BITMAP and GetDIBits, which drops alpha.
  Paste now reads PNG, else CF_DIBV5, else CF_DIB, decoded with their colour
  masks and tables (a BMP file header in front, which image's headerless
  decoder gets wrong for masks after a BITMAPINFOHEADER), opaque when the
  alpha is zero throughout as screenshots leave it, capped at 32768 px a side.
- Web: only SVG text pasted. The page's paste event now hands the first
  picture or file that is art to the app (VectorcraftApp::paste_from_host),
  pasted with the chord held, like the desktop's paste keys.
- Linux reads image/png already; its Paste menu item still waits for text
  (no cheap way to ask arboard for a bitmap), the paste keys take one.

Bitmaps paste as embedded images at the view centre, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:15:20 +07:00
BFlat f22c100cc5 Merge pull request #459 from storytold/fix/issue-458
Fix #458: Font Size and Leading combo boxes in the Character and Properties panels and the Control bar
2026-10-08 16:13:01 +07:00
bflatasticandClaude Opus 5.5 0c4df274aa Fix #458: Font Size and Leading combo boxes in the Character and Properties panels and the Control bar
The Properties panel's Font Size and Leading were plain typed fields and the
Control bar had no type controls. All three places now use the shared
editable combo (`widgets::spin_field`: typing, arrow and spinner stepping,
label scrubbing, a preset dropdown in the type unit):

- Character panel: the size and leading grid is shared with the Properties
  panel (`character::metrics_grid`), which now shows the font style, Font
  Size, Leading, Kerning and Tracking as combos and applies them to the
  Type tool's selected characters as the Character panel does.
- Control bar: with type selected or edited, a Character link (the Character
  panel in a popover), the font family and style menus and the Font Size combo.
- Presets as Illustrator lists them: sizes 6-72 pt; Leading has Auto first
  (`widgets::spin_field_auto`, `text.setStyle {leading: "auto"}`) and then
  the size ladder instead of 120% values; Kerning and Tracking -100...200.
- Font Size, Leading, Kerning and Tracking show blank where the selected
  text's values differ (`character::shared`); the spinner does nothing on a
  blank field instead of jumping to the step size.

Each pick is one `text.setStyle` / `text.setRangeStyle` step, so MCP and the
control channel are unchanged. Translations for the two new strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:10:14 +07:00
BFlat fa405da794 Merge pull request #453 from wstock/fix/issue-394-smart-guides
Part of #394: Smart Guides display options and Snapping Tolerance
2026-10-08 15:51:01 +07:00
bflatasticandClaude Opus 5.5 42fced4725 Smart Guides: "align" also on a handle already in line, Free Transform's move offset and slice readouts follow Measurement Labels (review of #453)
- `Targets::align_rects` tells `snap_point_with` an axis lined up even when
  nothing moved or Alignment Guides hide the line, so the "align" label stays.
- Free Transform's move offset is a measurement label (as the Selection
  tool's), its scale/rotate readouts Transform Tools; slice move/resize
  readouts follow Measurement Labels.
- Snap to Point (Smart Guides off) keeps the default look for moves too, as
  for drawn points and guides.
- Test: guides dragged out of a ruler (#451) follow Snapping Tolerance,
  Anchor/Path Labels and Measurement Labels; docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:46:15 +07:00
wstockandClaude Fable 5.1 14d7b3ddce Part of #394: Smart Guides display options and Snapping Tolerance
Color, Alignment Guides, Anchor/Path Labels, Measurement Labels,
Transform Tools, Object Highlighting and Snapping Tolerance are read:

- tools: `guides::GuideStyle` on `Targets` (`styled(cx)`) colours the
  lines and labels and leaves them out when their option is off, the
  snap itself unchanged; `ToolContext` gains the six fields and
  `snap_tol()`, replacing the 5 px literal for every smart guide snap
  (drawn points, moves, bounding-box handles, ruler guides, artboards,
  reference points). The tools gate their `Overlay::Measure` readouts on
  Measurement Labels (drawing, moving, ruler guides, artboards) or
  Transform Tools (scaling, rotating, Free Transform, transform tools).
- engine: `with_tool_cx` fills them from `Prefs` (the colour parsed, magenta
  as fallback); the colour's default is `#ff3dfc`, what the tools drew.
- ui-egui: the hover outline needs Object Highlighting and Smart Guides.
- Tests in tools (guides, select), engine (tests_prefs) and the canvas;
  docs/mcp.md and ROADMAP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:46:15 +07:00
BFlat d92ade9117 Merge pull request #456 from storytold/fix/issue-455
Fix #455: the toolbar's double arrow and Window › Toolbars switch one/two columns
2026-10-08 15:41:41 +07:00
bflatasticandClaude Opus 5.5 297f6e93d3 Fix #455: the toolbar's double arrow and Window › Toolbars switch one/two columns
The toolbar went to two columns whenever the window was shorter than the
single column (about 990 pt for Basic, more for Advanced), so on most
screens it was always double and the double arrow and the Window menu item
flipped a flag nothing read. The column count is now the user's choice
alone; a window too short for the tools scrolls them (#84).

- window.toolbarColumns takes {double?: bool} (set or toggle, returns the
  new state) and reports `checked`; the double arrow runs it.
- The Window › Toolbars item reads "Double Column" with a check mark;
  catalogs updated (cs, es, ja, pt-br, zh-hans, zh-hant) and an es @msg row
  for the new error.
- Headless tests: the arrow and the command toggle the column count in a
  700 pt window (both fail on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:38:54 +07:00
BFlat d8e79078e5 Merge pull request #454 from storytold/fix/issue-451
Part of #451: guides dragged out of the rulers snap, artboard guides
2026-10-08 15:28:04 +07:00
bflatasticandClaude Opus 5.5 5d65699043 Part of #451: guides dragged out of the rulers snap, artboard guides
Guides dragged out of a ruler did not snap at all (only Shift to the ruler's
ticks): they now go through the engine (`Session::ruler_guide`, a
`NewGuide` previewing `guide.add`, one undo step) and snap as a moved guide
does: with Smart Guides to the edges, centres (so the sides' midpoints) and
anchors of the art and of the artboards, labelled; with Smart Guides off,
View > Snap to Point pulls new and moved guides onto anchors. Moving art
with the Selection tool already snapped flush to artboard edges and centres
with Smart Guides on; a test now covers it.

Artboard guides: `Guide.artboard` holds an artboard id (written only when
set, so canvas guides save as before and older files open unchanged).
`guide.add {artboard}` makes one, `guide.list` reports it; a guide dragged
out of a ruler with the Artboard tool belongs to the active artboard. It is
drawn, picked and snapped to across its artboard only, moves with it
(`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`),
is copied with it (`artboard.duplicate`, Alt-drag copy) and deleted with it
(`artboard.delete`, the guide selection kept in step).

Guides as Layers rows with their own lock and hide (part d) stay open: ruler
guides are not layer nodes; Lock Guides locks them all, and Make Guides
paths are Layers rows already. Listed in the ROADMAP parity row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:23:18 +07:00
BFlat 5f9d280892 Merge pull request #452 from storytold/fix/issue-446
Fix #446: copy and paste artboards with their art
2026-10-08 15:06:53 +07:00
bflatasticandClaude Opus 5.5 a0819151a0 Fix #446: copy and paste artboards with their art
With the Artboard tool chosen, Copy and Cut take its artboard together with
the art fully inside it (when Move/Copy Artwork with Artboard is on), and
Paste brings both back in one undo step: Paste right of the last artboard,
Paste in Place / in Front / in Back where it was, into this or another
document, the art going back into the layers it came from (Paste on All
Artboards pastes the art alone). The clipboard carries the artboard
(Clipboard::artboard); a pasted artboard keeps its name when it is free.

Duplicate Artboards (the Artboards panel menu, and now a row dragged onto
New Artboard) copies the art too: artboard.duplicate {index?, art?} defaults
to the Artboard tool's artboard and moveArt option and reports the copies.
It shares copy_artboard with the Alt-drag copy (artboard.move {copy}).

New commands reachable over MCP: artboard.copy and artboard.cut
{index?, art?}; edit.copy / edit.cut run them while the Artboard tool is
chosen (and are enabled then with nothing selected). Docs (mcp.md), the
tool and panel docs, translations of the new labels and the ROADMAP follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:02:33 +07:00
BFlat e17699b7ea Merge pull request #450 from storytold/fix/issue-444
Fix #444: tool cursors are OS cursors, so the pointer no longer trails over the canvas
2026-10-08 14:44:05 +07:00
bflatasticandClaude Opus 5.5 f46527e0bd Fix #444: tool cursors are OS cursors, so the pointer no longer trails over the canvas
Over the canvas the window hid the system cursor and painted the tool's
cursor glyph into its own frame. That glyph reached the screen only after
the frame was rendered, queued (two frames by default) and composited, so
on Windows it visibly trailed the real pointer, on any GPU: the "sluggish
mouse" of the report. Frame times were not the cause (0.4 ms empty,
1.5 ms median on a 20 000-path document, and no repaints while idle).

- cursors: the glyphs are collected as shapes, tessellated by egui and
  rasterized into straight-alpha RGBA bitmaps (anti-aliased, hotspot on a
  pixel corner), cached per cursor and display scale so the same Arc is
  handed over every frame and egui-winit keeps its OS cursor.
- canvas: on Windows and Linux the tool cursor goes to the OS through
  ctx.set_cursor_image; the system moves it itself. macOS (bitmaps sized
  in points) and the web (no bitmap cursors in eframe) keep painting it.
- app: the wgpu surface queues one frame instead of two (LOW_LATENCY):
  the GPU only composites the CPU-rendered canvas, so everything drawn in
  the window follows the pointer a frame sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:41:22 +07:00
BFlat 1ce3a5c908 Merge pull request #448 from storytold/fix/issue-442
Fix #442: alike circular corners on rectangles that aren't square
2026-10-08 14:40:03 +07:00
BFlat 4254bf915e Merge pull request #441 from DvloperKing/es-errores
i18n: Spanish translations for error and status-bar messages
2026-10-08 14:39:46 +07:00
bflatasticandClaude Opus 5.5 44c7e64e1b Fix #442: alike circular corners on rectangles that aren't square
Live rectangles drew elliptical corners after an uneven scale in v0.4.0;
inconsistent on a rectangle that isn't square:

- doc: LiveShape::fold_scale moves an uneven scale left in `xf` (files
  saved before #291) into the size and radii; `transform` uses it, and
  `folded()`/`corner_style` give Live Corners document units.
- engine: object.setLiveShape folds that scale first, so setting a radius
  or kind (fields, dialog, widgets, MCP) gives circular corners on such
  shapes instead of keeping the ellipses.
- geom: max_corner_radius/fitted_corner_radius are the one limit every
  corner shares (half the shorter side).
- tools: corner widgets sit at the centres of the corners as drawn (a
  radius past the limit put them off the arc, further in along the long
  side), drag in document units on stretched shapes, and outline the
  edited corners in red at the limit, as the reference app does.
- svg: CSS export writes the drawn radii (CSS would draw a radius past the
  limit when its neighbours leave room).
- docs: mcp.md Live Corners, ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:37 +07:00
bflatasticandClaude Opus 5.5 7c7ce7f4fb i18n: Spanish messages say aplicación and match the UI's wording (review of #441)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:20 +07:00
Eliezer Hernandez f44155e098 i18n: Spanish translations for error and status-bar messages 2026-10-08 14:34:20 +07:00
BFlat 9533707893 Merge pull request #445 from storytold/fix/issue-443
Fix #443: installed fonts outside the font folders and fonts installed while the app runs reach the font menus
2026-10-08 14:34:12 +07:00
bflatasticandClaude Opus 5.5 64cbee2a96 Fix #443: installed fonts outside the font folders and fonts installed while the app runs reach the font menus
The scan read only the platform's font folders, once per session (or on
Character panel menu > Refresh Font List). Fonts Windows registers outside
its font folders (installed as shortcuts, or by programs into their own
folders) never listed, nor did fonts installed while the app was open.

- Windows: the font files registered under the local machine's and the
  user's Fonts registry keys outside the scanned folders are scanned too
  (windows-registry, the windows-rs project's safe registry API).
- Linux/BSD: the XDG data folders' fonts (XDG_DATA_HOME, XDG_DATA_DIRS:
  Flatpak, Snap, NixOS, Guix) and a Flatpak sandbox's host fonts
  (/run/host/fonts, local-fonts, user-fonts); macOS: /Network/Library/Fonts.
- The global database looks the platform's paths up again at each scan,
  and a scan's roots may be font files as well as folders.
- FontDb::installed_fonts_changed compares the folders the last scan read
  (their modification times, and the paths it started from); the app
  checks it when its window comes back to the front and rescans
  (text.rescanFonts) only when fonts were installed or removed meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:03:53 +07:00
BFlat e2a4173337 Merge pull request #440 from storytold/fix/issue-439
Fix #439: Shape Builder erase mode deletes edges, shows what will go and follows Alt live
2026-10-08 13:42:06 +07:00
bflatasticandClaude Opus 5.5 17f66003cf Fix #439: Shape Builder erase mode deletes edges, shows what will go and follows Alt live
Alt already deleted regions and pieces of open lines, but erase mode had
no edges of closed shapes, no distinct pointer, highlighted regions in the
merge-mode grey, only noticed Alt on a mouse move, and an erase drag could
flip mode at release. Like the reference app's erase mode:

- pathops::shape_builder(shapes, edges) returns a BuilderArrangement; with
  `edges` it also cuts the outlines of closed (and filled open) paths
  wherever other paths meet them. pathops::cut_out removes such a piece
  from a path: a closed subpath opens there, an open one splits or
  shortens, a piece all the way round takes its subpath, and a filled open
  path's invisible closing edge never comes back as a visible segment.
  Specks where paths only touch cut nothing; a zero-length input is no
  longer the source of edges running through it. encloses_area is shared
  with the tool's closing of filled open paths.
- BuilderMap (with_edges) hit-tests edges like line pieces (nearest wins);
  an erase gesture that crosses an edge into a region deletes the region,
  not the edge (erased_edges).
- shapeBuilder.merge {erase}: deletes the edges touched within tolerance
  (from every shape along them), after the regions; returns `edges`.
  shapeBuilder.regions lists `edges` (bounds, source). One undo step.
- Shape Builder tool: Alt held switches to erase mode and back on release:
  red mesh/edge highlights of what will be deleted, a minus pointer
  (Cursor::ShapeBuilder / ShapeBuilderErase, a plus in merge mode); a drag
  keeps the mode it started in. The canvas re-hovers the tool when a
  modifier changes, so the highlight follows Alt without a mouse move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:37:35 +07:00
BFlat 1b93336bdf Merge pull request #437 from bobtherobot/feat/floating-flyouts
Toolbar: tool flyouts tear off into floating toolbars
2026-10-08 13:04:27 +07:00
BFlat 9a4a632ea7 Merge pull request #436 from bobtherobot/fix/toolbar-flyouts
Toolbar: a right press or a corner press opens a tool group's flyout at once
2026-10-08 13:04:11 +07:00
bflatasticandClaude Opus 5.5 97906f2104 Toolbar: a click on the tear-off bar floats the group, window.floatTools, strips in workspaces (review of #437)
- A click on a flyout's tear-off bar, or the release of the long press
  that opened it over the bar, tears the flyout off too, as in
  Illustrator; dragging it still does.
- window.floatTools {tool, floating?} floats or puts back the group
  holding a tool, for the control channel and MCP's invoke_menu.
- Floating strips are part of user workspaces; the built-in ones float
  none, so Reset puts them back.
- Loaded preferences keep one strip per group, of known tools.
- No per-frame clone of the strips or copy of the active tool's id.
- docs/control-protocol.md, ROADMAP, Float Tool Group translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:01:40 +07:00
bflatasticandClaude Opus 5.5 b08188e5a5 Toolbar: only tool groups' buttons check for a flyout press, ROADMAP (review of #436)
A press on a single tool's button no longer schedules the repaint that
would open a flyout it doesn't have. The ROADMAP's UI list and Last
updated line name the right and corner press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:01:25 +07:00
Michael GiesonandClaude Opus 5.5 dcc8a24f66 Toolbar: tool flyouts tear off into floating toolbars
A grab bar runs down the right side of a tool group's flyout. Dragging it
tears the flyout off: the group floats as a strip of its tools' buttons
(the active one lit, each tool's name and shortcut in its tool tip), with
the bar on its right. The same bar moves the strip, and the × at the top
of the bar puts the group back in the toolbar. While a group floats, the
presses that would open its flyout (right, long or corner press) raise
the strip and flash its border instead, so a group is never both a
flyout and a floating strip. Floating strips are kept with the UI
preferences and kept on screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:01:25 +07:00
Michael GiesonandClaude Opus 5.5 e80b3543d3 Toolbar: a right press or a corner press opens a tool group's flyout at once
A right press opens the flyout on the press instead of on the release,
including on the active tool's button, and the release leaves it open. A
primary press on the button's corner triangle opens it at once without
picking the tool; a plain click in the middle still picks it. The long
press keeps scheduling the frame where it becomes long.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:01:13 +07:00
BFlat a6fceb295e Merge pull request #435 from bobtherobot/feat/layers-panel
Layers: drag ghost, drops on the bottom-bar buttons, trash deletes again
2026-10-08 13:01:07 +07:00
bflatasticandClaude Opus 5.5 a32aed7e81 Layers: only the trash and New Layer take dropped rows (review of #435)
Illustrator documents two drop targets in the Layers panel's bottom bar:
dragging items to Create New Layer duplicates them, to the Delete icon
deletes them. Dropping rows on New Sublayer or Make/Release Clipping
Mask no longer creates a sublayer or clips the row's layer; those
buttons only act on a click, and the new test checks a drop on New
Sublayer changes nothing. The trash and New Layer share one drop loop,
and the drag ghost paints its colour bar and thumbnail with the row's
own helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:58:21 +07:00
Michael GiesonandClaude Opus 5.5 a216c36779 Layers: drag ghost, drops on the bottom-bar buttons, trash deletes again
Dragging a row shows a dimmed copy of it (colour bar, thumbnail, name)
under the pointer. A row dropped on Delete, New Layer, New Sublayer or
Make/Release Clipping Mask runs that button on the row's layer, as a
click would: Delete removes the row itself and New Layer duplicates it,
as in Illustrator.

Rows dragged onto the trash weren't deleted. The trash took the drop as
a target-circle payload first, and egui's take discards a payload of
another type, so the rows reached the second check as nothing. It now
peeks at the type before taking. The trash is also outlined while rows
or a target circle are over it, like the other drop buttons.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:58:04 +07:00
Michael GiesonandClaude Opus 5.5 80f4de1a5e Layers: Layer Options' Color list shows a chip of each colour
Each preset in the Color dropdown has a chip of its colour before its
name; Custom has none, its name aligned with the others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:58:04 +07:00
BFlat cae68a57a1 Merge pull request #438 from storytold/fix/issue-429
Fix #429: Type on a path brackets, flip and Type on a Path Options that keep the type in place
2026-10-08 12:57:55 +07:00
132 changed files with 8264 additions and 1118 deletions

No files matched your search

+3 -2
View File
@@ -90,6 +90,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-alignto-key.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-alignto-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-anchor.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-anchor-smooth.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -171,8 +172,6 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pathfinder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pen-add.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pen-delete.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-perspective.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pf-crop.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-pf-divide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -265,6 +264,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/paintbrush.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/palette.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/pen-tool.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/pen-tool-add.svg` | Lucide Icons and Contributors; badge by VectorCraft contributors | https://lucide.dev (lucide-static v1.49.0 `pen-tool`; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Lucide's pen-tool with a plus badge |
| `assets/icons/pen-tool-delete.svg` | Lucide Icons and Contributors; badge by VectorCraft contributors | https://lucide.dev (lucide-static v1.49.0 `pen-tool`; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Lucide's pen-tool with a minus badge |
| `assets/icons/pencil.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/pilcrow.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/pin.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
Generated
+12
View File
@@ -4649,6 +4649,7 @@ dependencies = [
"unicode-script",
"vectorcraft-doc",
"vectorcraft-geom",
"windows-registry",
"write-fonts",
]
@@ -5421,6 +5422,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
+29 -28
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); see [Honest assessment](#honest-assessment-2026-10-05))._
_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); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -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 (#306); web drops land in the document they were dropped on (#359); 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); 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); hybrid-graphics laptops render on the power-saving GPU by default (#306); 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); 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) |
@@ -95,16 +95,17 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- 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.
- 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 and Linux, 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).
- 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.
- 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.
- 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), 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. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 45 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, the smart guide display options, 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). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 38 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…).
- 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 and dialogs step with Up/Down (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.
@@ -118,27 +119,27 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles; drawing then goes on.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **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, the trash deletes), 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 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: the dragged edges align with the other objects; 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 (Shift snaps them to the ruler's ticks) 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 art's edges, centres and 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.
- **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: the dragged edges align with the other objects; 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).
- **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.
- 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. 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.
- **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); 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 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; 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), 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.
- **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; Alt deletes their pieces; filled open paths count as closed), Live Paint (every path an edge, open lines included), Image Trace (12 presets), 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; 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).
- **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.
- **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, panels have their 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}`).
- **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.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
@@ -149,12 +150,12 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **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.
- `.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; 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, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- 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).
- 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, and falls back to its palette TIFF preview. 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).
- 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); 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 the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` 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).
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` 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).
- **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
@@ -168,7 +169,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| 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 (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, 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 |
| 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 |
@@ -196,7 +197,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|---|---:|---:|---|---:|
| 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 plain paths and polygons (and Relative rounding) | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection and options | 3–6 |
| 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 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
@@ -204,17 +205,17 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| 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), hanging punctuation, 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 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 |
| 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 (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) (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 | 80% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click) and on layers (dragging existing guides is done, #414) | 3–6 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against more real files (two Illustrator 30 files open as vectors so far), 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 | 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, Illustrator EPS checked against more real files (two Illustrator 30 files open as vectors so far), 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, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 45 Preferences options stored but not yet acted on (#394: smart guide display options, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Show Pixel Grid, performance, devices…) | 16–26 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 38 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, Show Pixel Grid, performance, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 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 |
@@ -234,7 +235,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance.
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Art off the page that a PDF does draw opens on the pasteboard.
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
+1 -1
View File
@@ -27,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
+2 -1
View File
@@ -11,7 +11,8 @@
//! - Save / Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod locks;
+90 -5
View File
@@ -1,14 +1,16 @@
//! The browser shell: web `Services`, drag-and-drop, the eframe web runner, and starting again
//! with new graphics when the page loses them (#369).
//! The browser shell: web `Services`, drag-and-drop, pasted pictures and files, the eframe web
//! runner, and starting again with new graphics when the page loses them (#369).
use std::cell::{Cell, RefCell};
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::fileio;
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
@@ -22,6 +24,14 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// whether Shift was held: where dropped files land.
type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
/// A picture or file pasted into the page, read and waiting for the next frame with the modifiers
/// held at the paste, and the context to wake for it (the current runner's).
#[derive(Default)]
struct Pasted {
flavour: Option<(Flavour, egui::Modifiers)>,
ctx: Option<egui::Context>,
}
const CANVAS_ID: &str = "vectorcraft_canvas";
const LOADING_ID: &str = "vectorcraft_loading";
@@ -36,6 +46,7 @@ struct Host {
inbox: Inbox,
place_inbox: PlaceInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
/// restart keeps them, so the tab stays the owner of its recovery area.
locks: Option<WebLocks>,
@@ -49,6 +60,7 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
detect_language();
wasm_bindgen_futures::spawn_local(async move {
// Before the first frame, which looks for copies a crash left behind.
let locks = WebLocks::start(RECOVERY_PREFIX).await;
@@ -57,9 +69,11 @@ pub fn start() {
inbox: Arc::default(),
place_inbox: Arc::default(),
drag: DragPos::default(),
pasted: Rc::default(),
locks,
restarts: 0,
};
track_paste(&host.pasted);
run(host, canvas, None).await;
});
}
@@ -92,6 +106,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
let (loss, ctx) = (loss.clone(), ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
host.pasted.borrow_mut().ctx = Some(ctx.clone());
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone());
let app = match taken.borrow_mut().take() {
Some(mut app) => {
@@ -131,6 +146,27 @@ fn web_options() -> eframe::WebOptions {
options
}
/// Tell the UI the browser's languages (`?lang=es` in the address first, as `VECTORCRAFT_LOCALE`
/// does on the desktop) before the first frame, so the Automatic interface language follows the
/// browser; and show the loading text in that language.
fn detect_language() {
let mut tags: Vec<String> = query_param("lang").into_iter().collect();
if let Some(navigator) = web_sys::window().map(|w| w.navigator()) {
tags.extend(navigator.languages().iter().filter_map(|v| v.as_string()));
tags.extend(navigator.language());
}
i18n::set_system_locales(tags.as_slice());
if let Some(el) = element(LOADING_ID) {
el.set_text_content(Some(i18n::message(i18n::system_lang(), "Loading VectorCraft…").as_str()));
}
}
/// The value of `name=` in the address's query, if given.
fn query_param(name: &str) -> Option<String> {
let query = query();
query.trim_start_matches('?').split('&').find_map(|kv| kv.strip_prefix(name)?.strip_prefix('=').map(str::to_string)).filter(|v| !v.is_empty())
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -155,6 +191,50 @@ fn track_drag(canvas: &web_sys::HtmlCanvasElement, pos: &DragPos) {
on_drag.forget();
}
/// Take pictures and files pasted into the page (egui reads a paste's text only): a paste without
/// text hands the first of its files that is art (a bitmap, SVG, PDF, a metafile) to the next
/// frame. Text, SVG markup among it, is left to egui.
fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
let Some(window) = web_sys::window() else { return };
let pasted = pasted.clone();
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()) {
return;
}
e.prevent_default();
let held = pasted.borrow().ctx.as_ref().map(|c| c.input(|i| i.modifiers)).unwrap_or_default();
let files: Vec<web_sys::File> = (0..files.length()).filter_map(|i| files.get(i)).collect();
let pasted = pasted.clone();
wasm_bindgen_futures::spawn_local(async move {
for file in files {
let bytes = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => js_sys::Uint8Array::new(&buf).to_vec(),
Err(e) => {
log::error!("couldn't read the pasted file {}: {}", file.name(), js_err(e));
continue;
}
};
if let Some(mime) = file_flavour(&file.name(), bytes.get(..FILE_HEAD).unwrap_or(&bytes), &PASTE_ORDER) {
let mut p = pasted.borrow_mut();
p.flavour = Some((Flavour { mime, data: bytes }, held));
if let Some(ctx) = &p.ctx {
ctx.request_repaint();
}
return;
}
}
});
});
// Captured before eframe's own listener, which stops the event.
if let Err(e) = window.add_event_listener_with_callback_and_bool("paste", on_paste.as_ref().unchecked_ref(), true) {
log::error!("couldn't follow paste events: {e:?}");
}
// The listener lives as long as the page.
on_paste.forget();
}
/// Report the loss of `canvas`'s WebGL context to `loss` (WebGPU devices report theirs through
/// wgpu).
fn watch_canvas(canvas: &web_sys::HtmlCanvasElement, loss: &GraphicsLoss, ctx: &egui::Context) {
@@ -215,7 +295,7 @@ fn notice(lines: &[&str]) {
el.set_text_content(None);
for line in lines {
if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(line));
p.set_text_content(Some(i18n::message(i18n::system_lang(), line).as_str()));
// Best effort: the log has the message too.
el.append_child(&p).ok();
}
@@ -261,15 +341,20 @@ impl eframe::App for WebShell {
self.restart(&why);
}
let Some(app) = &mut self.app else { return };
let pasted = self.host.pasted.borrow_mut().flavour.take();
if let Some((f, held)) = pasted {
app.paste_from_host(ctx, f, held);
}
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let target = app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
match target {
+3 -3
View File
@@ -39,9 +39,9 @@ clipboard-win = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSTextInputContext"] }
# Files opened from Finder and the Dock (src/open_documents.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "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"
[build-dependencies]
+220 -15
View File
@@ -2,9 +2,11 @@
//! (`Services::system_clipboard`).
//!
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
//! Paste reads each of them.
//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
//! else the PNG as a bitmap; Paste reads text and bitmaps. On Wayland, arboard's data-control
//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
@@ -30,10 +32,79 @@ fn text_of(flavours: &[Flavour]) -> Option<String> {
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
}
/// Most pixels a side of a bitmap another app put on the clipboard (untrusted data).
const MAX_SIDE: u32 = 32_768;
fn decode_png(png: &[u8]) -> Option<image::RgbaImage> {
Some(image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?.to_rgba8())
}
/// `img` as a PNG flavour.
fn png_flavour(img: &image::RgbaImage) -> Option<Flavour> {
let mut data = vec![];
img.write_to(&mut Cursor::new(&mut data), image::ImageFormat::Png).ok()?;
Some(Flavour { mime: PNG, data })
}
/// A Windows device-independent bitmap (`CF_DIB`, `CF_DIBV5`: a BITMAPINFO header, the colour
/// masks of a BITMAPINFOHEADER, the colour table, then the pixels) as a BMP file, whose header
/// says where the pixels start. `None` when the header is cut short or out of range.
#[cfg_attr(not(windows), allow(dead_code))]
fn dib_file(dib: &[u8]) -> Option<Vec<u8>> {
const BI_BITFIELDS: u32 = 3;
const BI_ALPHABITFIELDS: u32 = 6;
let u16_at = |i: usize| Some(u16::from_le_bytes(dib.get(i..i + 2)?.try_into().ok()?));
let u32_at = |i: usize| Some(u32::from_le_bytes(dib.get(i..i + 4)?.try_into().ok()?));
let header = u32_at(0)?;
// BITMAPCOREHEADER has 16-bit fields and 3-byte colours; the others 32-bit fields, 4-byte
// colours and a count of them (0: all a palette of the depth has), the 40-byte one its masks
// after it.
let (bits, used, entry, masks) = match header {
12 => (u16_at(10)?, 0, 3, 0),
_ => {
let masks = match (header, u32_at(16)?) {
(40, BI_BITFIELDS) => 12,
(40, BI_ALPHABITFIELDS) => 16,
_ => 0,
};
(u16_at(14)?, u32_at(32)?, 4, masks)
}
};
let colours = match used {
0 if bits <= 8 => 1 << bits,
n if n <= 256 => n,
_ => return None,
};
let offset = [header, masks, colours * entry].into_iter().try_fold(14u32, u32::checked_add)?;
let size = u32::try_from(dib.len()).ok()?.checked_add(14)?;
if offset > size {
return None;
}
let mut bmp = Vec::with_capacity(dib.len() + 14);
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&size.to_le_bytes());
bmp.extend_from_slice(&[0; 4]);
bmp.extend_from_slice(&offset.to_le_bytes());
bmp.extend_from_slice(dib);
Some(bmp)
}
/// A device-independent bitmap as an image, at most [`MAX_SIDE`] pixels a side. It is opaque when
/// its alpha is zero throughout: screenshots and most apps leave the fourth byte of a 32-bit pixel
/// at zero, even under an alpha mask.
#[cfg_attr(not(windows), allow(dead_code))]
fn dib_image(dib: &[u8]) -> Option<image::RgbaImage> {
let mut reader = image::ImageReader::with_format(Cursor::new(dib_file(dib)?), image::ImageFormat::Bmp);
let mut limits = image::Limits::default();
(limits.max_image_width, limits.max_image_height) = (Some(MAX_SIDE), Some(MAX_SIDE));
reader.limits(limits);
let mut img = reader.decode().ok()?.into_rgba8();
if img.pixels().all(|p| p.0[3] == 0) {
img.pixels_mut().for_each(|p| p.0[3] = u8::MAX);
}
Some(img)
}
/// The first of `paths` (files copied in a file manager) that pastes as one of `mimes`, read
/// whole. Only its first bytes are read to tell, so other files cost little.
fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
@@ -71,6 +142,10 @@ mod win {
}
}
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
/// other or a `CF_BITMAP` is: the system converts between them.
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
}
@@ -162,7 +237,7 @@ mod win {
raw::is_format_avail(formats::CF_HDROP)
|| mimes.iter().any(|m| match *m {
TEXT => raw::is_format_avail(formats::CF_UNICODETEXT),
BITMAP => raw::is_format_avail(formats::CF_BITMAP) || formats_of(PNG).any(raw::is_format_avail),
BITMAP => DIBS.into_iter().chain(formats_of(PNG)).any(raw::is_format_avail),
m => formats_of(m).any(raw::is_format_avail),
})
}
@@ -182,9 +257,11 @@ mod win {
let mime = match mime {
BITMAP if read_registered(PNG, &mut data) => PNG,
BITMAP => {
data.clear();
raw::get_bitmap(&mut data).ok()?;
"image/bmp"
return DIBS.into_iter().find_map(|id| {
data.clear();
raw::get_vec(id, &mut data).ok()?;
png_flavour(&dib_image(&data)?)
});
}
TEXT => {
raw::get_string(&mut data).ok()?;
@@ -210,13 +287,6 @@ mod portable {
use super::*;
/// `img` as PNG bytes.
fn encode_png(img: &image::RgbaImage) -> Option<Vec<u8>> {
let mut out = vec![];
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
}
/// What our last write put on the clipboard.
enum Ours {
Text(String),
@@ -283,21 +353,65 @@ mod portable {
mimes.iter().find_map(|m| match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
BITMAP => {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::bitmap() {
return Some(f);
}
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
encode_png(&rgba).map(|data| Flavour { mime: PNG, data })
png_flavour(&rgba)
}
_ => None,
})
}
/// Text only: reading a bitmap costs as much as pasting it.
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
/// menu item waits for text.
fn has(&mut self, mimes: &[&'static str]) -> bool {
#[cfg(target_os = "macos")]
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
return true;
}
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
}
}
}
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
#[cfg(target_os = "macos")]
mod mac {
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSPasteboard;
use objc2_foundation::{NSArray, NSString};
use super::*;
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
/// `NSPasteboardTypeTIFF`), with their MIME types.
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
/// Does the pasteboard hold a bitmap? Asks for its types only.
pub fn has_bitmap() -> bool {
autoreleasepool(|_| {
let types = TYPES.map(|(t, _)| NSString::from_str(t));
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
})
}
/// The pasteboard's bitmap, PNG before TIFF.
pub fn bitmap() -> Option<Flavour> {
autoreleasepool(|_| {
let pasteboard = NSPasteboard::generalPasteboard();
TYPES.into_iter().find_map(|(t, mime)| {
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
(!data.is_empty()).then_some(Flavour { mime, data })
})
})
}
}
#[cfg(test)]
mod tests {
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
@@ -341,4 +455,95 @@ mod tests {
let with_cr = PathBuf::from(format!("{}\r", red.display()));
assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG));
}
/// A DIB of `w`×`h` pixels (top row first when `h` is negative): a `header`-byte header (40,
/// or 124 for a BITMAPV5HEADER with BGRA masks), `extra` (the masks after a 40-byte header, a
/// colour table), then `rows`, each padded to 4 bytes.
fn dib(header: u32, w: i32, h: i32, bits: u16, compression: u32, extra: &[u8], rows: &[u8]) -> Vec<u8> {
let mut d = vec![];
d.extend(header.to_le_bytes());
d.extend(w.to_le_bytes());
d.extend(h.to_le_bytes());
d.extend(1u16.to_le_bytes());
d.extend(bits.to_le_bytes());
d.extend(compression.to_le_bytes());
d.extend((rows.len() as u32).to_le_bytes());
// 96 ppi, every colour of the table used.
d.extend([3780u32, 3780, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
if header == 124 {
d.extend([0x00FF_0000u32, 0xFF00, 0xFF, 0xFF00_0000, 0x7352_4742].iter().flat_map(|v| v.to_le_bytes()));
d.resize(124, 0);
}
d.extend_from_slice(extra);
d.extend_from_slice(rows);
d
}
/// 32-bit BGRA rows of `px` (top row first).
fn bgra(px: &[[u8; 4]]) -> Vec<u8> {
px.iter().flat_map(|[r, g, b, a]| [*b, *g, *r, *a]).collect()
}
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
fn pixels(img: &image::RgbaImage) -> Vec<[u8; 4]> {
img.pixels().map(|p| p.0).collect()
}
#[test]
fn a_screenshot_dib_pastes_opaque_and_upright() {
// CF_DIB as screenshots copy it: 32-bit BI_RGB, bottom row first, the fourth byte zero.
let rows = bgra(&[[0, 0, 255, 0], [0, 0, 255, 0], [255, 0, 0, 0], [255, 0, 0, 0]]);
let img = dib_image(&dib(40, 2, 2, 32, 0, &[], &rows)).unwrap();
assert_eq!(pixels(&img), [RED, RED, BLUE, BLUE]);
// The same bitmap with an alpha mask (CF_DIBV5), alpha still zero throughout: opaque too.
let img = dib_image(&dib(124, 2, -2, 32, 3, &[], &bgra(&[[255, 0, 0, 0]; 4]))).unwrap();
assert_eq!(pixels(&img), [RED; 4]);
}
#[test]
fn a_dibv5_keeps_its_alpha_top_down() {
// A negative height: top row first.
let rows = bgra(&[[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 0], [0, 0, 255, 255]]);
let img = dib_image(&dib(124, 2, -2, 32, 3, &[], &rows)).unwrap();
assert_eq!(pixels(&img), [[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 0], BLUE]);
}
#[test]
fn masks_and_colour_tables_come_before_the_pixels() {
// BI_BITFIELDS after a 40-byte header: three masks between it and the pixels.
let masks: Vec<u8> = [0x00FF_0000u32, 0xFF00, 0xFF].iter().flat_map(|v| v.to_le_bytes()).collect();
let img = dib_image(&dib(40, 1, 1, 32, 3, &masks, &bgra(&[[0, 0, 255, 0]]))).unwrap();
assert_eq!(pixels(&img), [BLUE]);
// 8-bit, two colours used, rows padded to 4 bytes, bottom row first.
let mut table = bgra(&[RED, BLUE]);
table[3] = 0;
let mut d = dib(40, 3, 2, 8, 0, &table, &[1, 1, 1, 0, 0, 1, 0, 0]);
d[32..36].copy_from_slice(&2u32.to_le_bytes());
assert_eq!(pixels(&dib_image(&d).unwrap()), [RED, BLUE, RED, BLUE, BLUE, BLUE]);
// 24-bit, rows padded.
let img = dib_image(&dib(40, 1, 2, 24, 0, &[], &[255, 0, 0, 0, 0, 0, 255, 0])).unwrap();
assert_eq!(pixels(&img), [RED, BLUE]);
}
#[test]
fn untrusted_dibs_are_refused_not_trusted() {
let one = bgra(&[RED]);
// Too large a side (the pixels aren't even there), cut short, a header or a colour count
// out of range, no pixels.
assert!(dib_image(&dib(40, MAX_SIDE as i32 + 1, 1, 32, 0, &[], &one)).is_none());
assert!(dib_image(&dib(40, 1, -(MAX_SIDE as i32) - 1, 32, 0, &[], &one)).is_none());
assert!(dib_image(&dib(40, 4096, 4096, 32, 0, &[], &one)).is_none());
assert!(dib_image(&[40, 0, 0]).is_none());
assert!(dib_image(&[]).is_none());
let mut d = dib(40, 1, 1, 32, 0, &[], &one);
d[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(dib_image(&d).is_none());
let mut d = dib(40, 1, 1, 8, 0, &[], &[0; 4]);
d[32..36].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(dib_file(&d).is_none());
assert!(dib_image(&dib(40, 1, 1, 32, 0, &[], &[])).is_none());
assert!(dib_image(&dib(40, 0, 0, 32, 0, &[], &[])).is_none());
}
}
+582
View File
@@ -0,0 +1,582 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the graphics adapter, a
//! lost graphics device, a panic the engine's guard recovered from) vanished. A launch from a desktop menu
//! has no terminal, so the file is what a bug report can attach: `<settings dir>/logs/vectorcraft.log`,
//! next to the preferences (`prefs_path` in `main.rs`). Each start moves the previous log to
//! `vectorcraft.1.log` (and that one to `.2`), so the log of a run that crashed survives the next
//! launch. Runs without preferences (`VECTORCRAFT_NO_PREFS`) log to standard error only.
//!
//! Levels: `info` for VectorCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,vectorcraft_render=trace`, `wgpu_core=info`. A directive ending in `*` matches every
//! target that starts with it (`vectorcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
//!
//! This is the desktop app's logger only. `vectorcraft-cli mcp` installs its own
//! (`vectorcraft_mcp::logging`, MCP `notifications/message`); the two never share a process.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "vectorcraft.log";
/// How many previous logs are kept (`vectorcraft.1.log` … `vectorcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,vectorcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("vectorcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
// No file is coming ([`Sink::no_file`]): standard error had the line.
} else if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
self.written = 0;
self.capped = false;
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// There will be no log file: forget the lines kept for it and keep no more.
pub fn no_file(&mut self) {
self.pending = Vec::new();
self.dropped = 0;
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
/// standard error stays the only sink.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
// A thread that panicked while logging leaves the sink usable: take it back.
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
match rotate(dir) {
Ok((path, file)) => {
sink.attach(file);
Ok(path)
}
Err(e) => {
sink.no_file();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file for this run (runs without preferences): log to standard error only.
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn the_default_filter_shows_vectorcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("vectorcraft"), LevelFilter::Info);
assert_eq!(f.level_for("vectorcraft::window"), LevelFilter::Info);
assert_eq!(f.level_for("vectorcraft_engine::guard"), LevelFilter::Info);
assert_eq!(f.level_for("vectorcraft_ui_egui::io"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,vectorcraft_render=trace,vectorcraft_render::tiles=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("vectorcraft_render::canvas"), LevelFilter::Trace);
assert_eq!(f.level_for("vectorcraft_render::tiles"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("vectorcraft*=debug").level_for("vectorcraft_text::shape"), LevelFilter::Debug);
assert_eq!(Filter::parse("vectorcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("vectorcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , vectorcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"vectorcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,vectorcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "vectorcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,vectorcraft=DEBUG").level_for("vectorcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("vectorcraft=").level_for("vectorcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "vectorcraft", "rendering with Mesa (Vulkan, IntegratedGpu)"),
"2026-10-08T07:59:17.728Z INFO [main] vectorcraft: rendering with Mesa (Vulkan, IntegratedGpu)\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("vectorcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("vectorcraft.2.log")), "run 2");
assert!(!dir.join("vectorcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "vectorcraft", "before the settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Error, "vectorcraft_engine::guard", "recovered from a panic: boom");
record(log::Level::Debug, "vectorcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("vectorcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("vectorcraft_engine::guard: recovered from a panic: boom"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("vectorcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("vectorcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
use log::Log;
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
l.log(
&log::Record::builder().level(log::Level::Info).target("vectorcraft").args(format_args!("thread {t} record {i}")).build(),
);
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" vectorcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
// Nothing waits in memory for a file that won't come.
assert!(logger.sink.lock().expect("sink").pending.is_empty());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
use log::Log;
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
logger.log(&log::Record::builder().level(log::Level::Warn).target("vectorcraft").args(format_args!("after the poison")).build());
assert!(read(&path).contains("vectorcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+26 -1
View File
@@ -16,6 +16,7 @@ compile_error!("the win7 target requires --no-default-features --features window
mod clipboard;
mod control_server;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
@@ -139,6 +140,11 @@ fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
base.map(|b| b.join("ui.json"))
}
/// Where the log files live: `logs` in the preferences folder (see `logging`).
fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
@@ -319,6 +325,8 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
fn main() -> eframe::Result {
// First, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
@@ -333,6 +341,20 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
// don't rotate away the user's own logs.
if let Some(logger) = logger {
match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => match logger.attach_dir(&dir) {
Ok(path) => log::info!("VectorCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
},
None => logger.no_file(),
}
}
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
#[cfg(feature = "wgpu")]
@@ -358,6 +380,9 @@ fn main() -> eframe::Result {
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
// composites it, so the window answers the pointer a frame sooner (#444).
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.power_preference = power;
}
@@ -391,7 +416,7 @@ fn main() -> eframe::Result {
#[cfg(feature = "wgpu")]
if let Some(rs) = &cc.wgpu_render_state {
let summary = adapter_summary(&rs.adapter.get_info());
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
log::info!("rendering with {summary} (power preference {power:?})");
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
+2 -2
View File
@@ -59,7 +59,7 @@ pub fn take() -> Vec<String> {
fn add_open_urls_method() {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
log::warn!("vectorcraft: no application delegate; files opened from Finder won't open");
log::warn!("no application delegate; files opened from Finder won't open");
return;
};
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
@@ -72,7 +72,7 @@ fn add_open_urls_method() {
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
};
if !added.as_bool() {
log::warn!("vectorcraft: the application delegate already handles open-document events");
log::warn!("the application delegate already handles open-document events");
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 20 C5 11 8 7 12 7 S19 11 21 20"/><path d="M5 7 H19"/><circle cx="5" cy="7" r="1.4" fill="currentColor"/><circle cx="19" cy="7" r="1.4" fill="currentColor"/><rect x="10" y="5" width="4" height="4" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 419 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l-7-7 3-6 8 3 3 7-7 3z" /><path d="M5 12l5-5"/><circle cx="11" cy="11" r="1.5"/><path d="M19 3v5M16.5 5.5h5"/></svg>

Before

Width:  |  Height:  |  Size: 315 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l-7-7 3-6 8 3 3 7-7 3z" /><path d="M5 12l5-5"/><circle cx="11" cy="11" r="1.5"/><path d="M16.5 5.5h5"/></svg>

Before

Width:  |  Height:  |  Size: 308 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<!-- Lucide's pen-tool with a plus badge added for VectorCraft -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M4 18.3v4.4M1.8 20.5h4.4" />
</svg>

After

Width:  |  Height:  |  Size: 697 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<!-- Lucide's pen-tool with a minus badge added for VectorCraft -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M1.8 20.5h4.4" />
</svg>

After

Width:  |  Height:  |  Size: 687 B

+33 -17
View File
@@ -1,8 +1,10 @@
//! Clipped art read from page descriptions (metafiles, PostScript) into clipping groups.
//! Clipped and grouped art read from page descriptions (metafiles, PostScript) into clipping
//! groups and groups.
//!
//! Importers record each object they draw with the chain of clips in force at the time, outermost
//! first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into one clipping
//! group, nesting groups for nested clips, keeping the drawing order.
//! Importers record each object they draw with the chain of clips (and groups) in force at the
//! time, outermost first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into
//! one clipping group (under the same group into one group), nesting groups for nested clips,
//! keeping the drawing order.
use std::sync::Arc;
@@ -11,20 +13,30 @@ use vectorcraft_geom::{BezPath, FillRule, PathData};
use crate::appearance::Appearance;
use crate::{Document, Node, NodeKind};
/// A clip: a region in document space and its rule. Each has an id unique within an import, so
/// what one clip clips can be grouped.
/// A clip: a region in document space and its rule, or a plain group (one that holds its art
/// without clipping it, such as an Illustrator file's `u` … `U`). Each has an id unique within an
/// import, so what one clip clips (or one group holds) can be grouped.
#[derive(Debug)]
pub struct Clip {
pub id: u32,
pub path: BezPath,
pub rule: FillRule,
/// The region and its rule; `None` for a plain group.
pub region: Option<(BezPath, FillRule)>,
}
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
/// for each): deeper ones are left out.
pub const MAX_NEST: usize = 128;
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
pub fn deep_clips() -> String {
format!("clips nested more than {MAX_NEST} deep were left out: what they clip is clipped by the clips around them")
}
/// Objects in drawing order, each with its clips (outermost first).
pub type Drawn = Vec<(Vec<Arc<Clip>>, Node)>;
/// The nodes of `items`, those under clips grouped into clipping groups (ids from `doc`), in
/// order.
/// The nodes of `items`, those under clips grouped into clipping groups and those in groups into
/// groups (ids from `doc`), in order.
pub fn nest(doc: &mut Document, items: Drawn) -> Vec<Arc<Node>> {
nest_at(doc, items, 0)
}
@@ -53,14 +65,18 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
out
}
/// A clipping group of `items`, clipped by `c` (their clip at `depth`).
/// A clipping group of `items`, clipped by `c` (their clip at `depth`), or their group when `c` is
/// a plain group.
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<Node> {
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default());
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
*clipping = true;
*rule = c.rule;
let mut children = vec![];
if let Some((bp, r)) = &c.region {
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(bp), Appearance::default());
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
*clipping = true;
*rule = *r;
}
children.push(Arc::new(path));
}
let mut children = vec![Arc::new(path)];
children.extend(nest_at(doc, items, depth + 1));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true }))
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
}
+60 -3
View File
@@ -78,15 +78,15 @@ 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, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign,
PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
};
pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{Swatch, SwatchGroup};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_geom::{Point, Rect, Vec2};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
@@ -386,6 +386,22 @@ pub struct Guide {
/// true = vertical guide at `pos` (x), false = horizontal at `pos` (y).
pub vertical: bool,
pub pos: f64,
/// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artboard: Option<u32>,
}
impl Guide {
/// A canvas guide.
pub fn new(vertical: bool, pos: f64) -> Self {
Self { vertical, pos, artboard: None }
}
/// Moved by `d` (a vertical guide across, a horizontal one down).
pub fn moved(&self, d: Vec2) -> Self {
Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -940,6 +956,47 @@ impl Document {
}
art
}
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
/// artboard is gone.
pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> {
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
}
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
let along = if g.vertical { p.y } else { p.x };
self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol)
}
/// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their
/// place among the rest (the selected art stays selected). Returns how many went.
pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize {
let kept: Vec<bool> = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect();
// Where each kept guide ends up.
let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0);
for k in &kept {
to.push(k.then_some(n));
n += usize::from(*k);
}
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
let before = self.guides.len();
let mut flags = kept.into_iter();
self.guides.retain(|_| flags.next().unwrap_or(true));
before - self.guides.len()
}
/// Move the guides of artboard `id` by `d` (along with their artboard).
pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) {
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
*g = g.moved(d);
}
}
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
/// artboard).
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {
let copies: Vec<Guide> =
self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect();
self.guides.extend(copies);
}
pub fn next_artboard_id(&self) -> u32 {
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
}
+63 -19
View File
@@ -143,24 +143,11 @@ impl LiveShape {
/// scale in `xf` keep their exact geometry until they're next transformed).
pub fn transform(&mut self, a: Affine) -> bool {
match self {
LiveShape::Rectangle { w, h, radii, xf, .. } => {
let m = a * *xf;
LiveShape::Rectangle { xf, .. } => {
*xf = a * *xf;
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
if moves_only || !orthogonal || !finite {
*xf = m;
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
!moves_only && self.fold_scale()
}
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
*xf = a * *xf;
@@ -173,6 +160,35 @@ impl LiveShape {
}
}
}
/// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so
/// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the
/// document. False (and nothing changes) when there is no scale to move, when `xf` shears or
/// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an
/// uneven scale in `xf`: their corners are elliptical until this runs.
pub fn fold_scale(&mut self) -> bool {
let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false };
let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12;
if !orthogonal || !finite || unscaled {
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
}
/// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document
/// units (what Live Corners show and edit).
pub fn folded(&self) -> Self {
let mut s = self.clone();
s.fold_scale();
s
}
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
/// keep their size (Scale Corners off). False when nothing changed.
pub fn keep_corners(&mut self, k: f64) -> bool {
@@ -212,15 +228,16 @@ impl LiveShape {
pub fn corner_anchors(&self, corners: [bool; 4]) -> BTreeSet<AnchorRef> {
self.anchor_corners().into_iter().enumerate().filter(|(_, k)| corners.get(*k) == Some(&true)).map(|(ai, _)| (0, ai)).collect()
}
/// The radius and kind the `corners` of a rectangle share (each `None` when they differ).
/// The radius (in document units, see [`LiveShape::folded`]) and kind the `corners` of a
/// rectangle share (each `None` when they differ).
pub fn corner_style(&self, corners: [bool; 4]) -> (Option<f64>, Option<CornerKind>) {
let LiveShape::Rectangle { radii, kinds, .. } = self else { return (None, None) };
let LiveShape::Rectangle { radii, kinds, .. } = self.folded() else { return (None, None) };
fn shared<T: Copy>(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option<T> {
let mut it = v.into_iter().zip(corners).filter(|(_, on)| *on).map(|(x, _)| x);
let first = it.next()?;
it.all(|x| same(x, first)).then_some(first)
}
(shared(*radii, corners, |a, b| (a - b).abs() < 1e-9), shared(*kinds, corners, |a, b| a == b))
(shared(radii, corners, |a, b| (a - b).abs() < 1e-9), shared(kinds, corners, |a, b| a == b))
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
pub fn angle_deg(&self) -> f64 {
@@ -1395,6 +1412,33 @@ mod tests {
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
}
/// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live
/// Corners see and edit it in document units, with circular corners (#442).
#[test]
fn a_scale_left_in_the_transform_folds_into_the_size() {
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0);
let n = live_rect(50.0, 80.0, 20.0, legacy);
let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") };
let folded = live.folded();
assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone");
let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") };
// Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius.
assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}");
assert!(near_xf(*xf, Affine::translate((5.0, 5.0))));
let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap());
assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}");
assert_eq!(live.corner_style([true; 4]).0, Some(40.0), "the radius in document units");
// Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are.
let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0);
let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]);
for xf in [place, shear] {
let mut l = live_rect(100.0, 160.0, 20.0, xf);
let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") };
assert!(!live.fold_scale());
assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf));
}
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+1 -1
View File
@@ -170,7 +170,7 @@ mod tests {
assert!(s.guides.is_empty());
// Pruning drops guides the document no longer has.
let mut d = Document::new(100.0, 100.0);
d.guides.push(crate::Guide { vertical: true, pos: 10.0 });
d.guides.push(crate::Guide::new(true, 10.0));
s.set_guides([0, 1]);
s.prune(&d);
assert_eq!(s.guides, vec![0]);
+26
View File
@@ -314,6 +314,10 @@ pub struct ParaStyle {
/// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top
/// of one line's ideographic em box to the next.
pub leading_model: LeadingModel,
/// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line
/// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before
/// it and imported text keep [`Burasagari::None`].
pub burasagari: Burasagari,
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
@@ -347,6 +351,8 @@ struct ParaStyleFile {
direction: Option<ParaDirection>,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
leading_model: LeadingModel,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
burasagari: Burasagari,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -369,6 +375,7 @@ impl From<ParaStyle> for ParaStyleFile {
mojikumi,
direction,
leading_model,
burasagari,
..
} = p;
Self {
@@ -385,6 +392,7 @@ impl From<ParaStyle> for ParaStyleFile {
mojikumi,
direction,
leading_model,
burasagari,
}
}
}
@@ -404,6 +412,7 @@ impl From<ParaStyleFile> for ParaStyle {
mojikumi,
direction,
leading_model,
burasagari,
..
} = f;
Self {
@@ -419,6 +428,7 @@ impl From<ParaStyleFile> for ParaStyle {
mojikumi,
direction,
leading_model,
burasagari,
}
}
}
@@ -465,6 +475,22 @@ impl Mojikumi {
}
}
/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。)
/// stands outside the line's measure, in the space its punctuation spacing gives it (half width
/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown
/// as None / Regular / Force.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Burasagari {
/// A comma or full stop that doesn't fit goes to the next line with the character before it.
#[default]
None,
/// A comma or full stop that doesn't fit hangs outside the line; one that fits stays inside.
Standard,
/// A comma or full stop ending a line always hangs, and the rest of the line fills the measure.
Forced,
}
/// Area Type Options "First Baseline" offset.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+44 -18
View File
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline,
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (Alt-drag deletes the regions, and the pieces of unfilled open paths within tolerance), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines}",
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
has_selection_or_ids,
sb_merge
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder Regions",
[],
None,
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose) and the pieces of those open paths",
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
has_selection_or_ids,
sb_regions
),
@@ -225,14 +225,14 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
}
let erase = bool_or(p, "erase", false);
// Line pieces are only deleted (Alt), never merged.
let line_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
// Line pieces and edges are only deleted (erase mode), never merged.
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let fill = fill_param(s, p, C)?;
let current_fill = s.paint.fill.clone();
let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, regions, lines) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots);
let (ids, merged, touched) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots, erase);
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
@@ -240,11 +240,15 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
let mut touched = Touched::default();
for q in sample_polyline(&pts, step) {
touched.add(map.hit(q, line_tol));
touched.add(map.hit(q, piece_tol));
}
touched.edges = map.erased_edges(&touched);
if touched.is_empty() {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
}
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
// 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) };
// The merged shape takes the look of the front-most object over the first region, or (for an
@@ -269,11 +273,26 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
} else if !picked.iter().any(|r| r.sources.contains(&i)) {
new_nodes.push(d.reid(leaf));
} else {
let rest = po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference);
if !rest.is_empty() {
// A shape loses the regions deleted or merged, then the edges deleted (each one on
// 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)
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
let fill_closes = !leaf.appearance.fill_paint().is_none();
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
rest = r;
cut = true;
}
}
if !in_union && !cut {
new_nodes.push(d.reid(leaf));
} else if !rest.is_empty() {
new_nodes.push(shape_node(d, rest, Some(leaf)));
}
}
@@ -288,9 +307,15 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
let new_ids = replace_roots(d, &roots, new_nodes)?;
sel.set(new_ids.iter().copied());
Ok((new_ids, merged_id, touched.regions.len(), touched.lines.len()))
Ok((new_ids, merged_id, touched))
})?;
Ok(json!({ "ids": ids_json(&ids), "merged": merged.map(|m| m.0), "regions": regions, "lines": lines }))
Ok(json!({
"ids": ids_json(&ids),
"merged": merged.map(|m| m.0),
"regions": touched.regions.len(),
"lines": touched.lines.len(),
"edges": touched.edges.len(),
}))
}
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
@@ -315,7 +340,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::new(d, &roots);
let (map, leaves) = BuilderMap::new(d, &roots, true);
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
@@ -328,9 +353,10 @@ fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
})
})
.collect();
let lines: Vec<Value> =
map.lines.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect();
Ok(json!({ "regions": regions, "lines": lines }))
let pieces = |list: &[(po::Shape, Rect)]| -> Vec<Value> {
list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect()
};
Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) }))
}
// ---------- Live Paint ----------
+4 -1
View File
@@ -87,11 +87,14 @@ pub struct Clipboard {
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
/// own resources as they are now.
pub source_doc: Option<u64>,
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
/// adds a copy of it with the art.
pub artboard: Option<vectorcraft_doc::Artboard>,
}
impl Clipboard {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
self.nodes.is_empty() && self.artboard.is_none()
}
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
+8 -2
View File
@@ -424,9 +424,15 @@ 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. Imported text keeps its own.
/// with Line-end Punctuation Half Width and Standard burasagari (as in Illustrator). Imported text
/// keeps its own.
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, ..Default::default() }
vectorcraft_doc::ParaStyle {
justify: vectorcraft_doc::Justify::Auto,
mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf,
burasagari: vectorcraft_doc::Burasagari::Standard,
..Default::default()
}
}
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
+37 -10
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection, TextKind};
use vectorcraft_geom::Vec2;
use super::edit::selected_roots;
use super::*;
@@ -154,8 +155,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
clear_guides
),
cmd!("guide.add", "Add Guide", [], None, "{vertical: bool, pos: pt (x for vertical, y for horizontal)} → {index}", has_doc, guide_add),
cmd!(query "guide.list", "Guides", [], None, "{} → [{index, vertical, pos, selected}…] the ruler guides", has_doc, guide_list),
cmd!(
"guide.add",
"Add Guide",
[],
None,
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}",
has_doc,
guide_add
),
cmd!(
query "guide.list",
"Guides",
[],
None,
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides",
has_doc,
guide_list
),
cmd!(
"guide.select",
"Select Guides",
@@ -481,8 +498,16 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
let pos = f64_req(p, "pos", "guide.add")?;
let vertical = bool_or(p, "vertical", false);
let artboard = match p.get("artboard").filter(|v| !v.is_null()) {
Some(v) => {
let boards = &s.doc()?.doc.artboards;
let ab = v.as_u64().and_then(|i| boards.get(usize::try_from(i).ok()?)).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
Some(ab.id)
}
None => None,
};
let i = s.edit("New Guide", |d, _| {
d.guides.push(Guide { vertical, pos });
d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) });
Ok(d.guides.len() - 1)
})?;
Ok(json!({ "index": i }))
@@ -490,7 +515,13 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let row = |(i, g): (usize, &Guide)| json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) });
let row = |(i, g): (usize, &Guide)| {
let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) });
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
r["artboard"] = json!(ab);
}
r
};
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
}
@@ -533,11 +564,7 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
}
let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" };
s.edit(label, |d, sel| {
let kept = |i: &usize| !gone.contains(i);
// The guides left selected keep their place among the rest.
let left: Vec<usize> = sel.guides.iter().filter(|i| kept(i)).map(|i| i - gone.iter().filter(|g| *g < i).count()).collect();
d.guides = std::mem::take(&mut d.guides).into_iter().enumerate().filter(|(i, _)| kept(i)).map(|(_, g)| g).collect();
sel.set_guides(left);
d.retain_guides(sel, |i, _| !gone.contains(&i));
Ok(())
})?;
Ok(json!({ "count": gone.len() }))
@@ -565,7 +592,7 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
let mut copies = vec![];
for &i in &moving {
let Some(g) = d.guides.get_mut(i) else { continue };
let moved = Guide { vertical: g.vertical, pos: g.pos + if g.vertical { dx } else { dy } };
let moved = g.moved(Vec2::new(dx, dy));
if copy {
copies.push(moved);
} else {
+7 -45
View File
@@ -670,34 +670,13 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Convert Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
let n = sp.anchors.len();
if ai >= n {
return Err(EngineError::Other("no such anchor".into()));
}
let prev = if ai > 0 || sp.closed { Some(sp.anchors[(ai + n - 1) % n].p) } else { None };
let next = if ai + 1 < n || sp.closed { Some(sp.anchors[(ai + 1) % n].p) } else { None };
let a = &mut sp.anchors[ai];
if !smooth {
a.retract();
return Ok(());
}
let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
match target {
Some(t) if t.distance(a.p) > 1e-9 => {
a.h_out = t;
a.h_in = a.p - (t - a.p);
a.kind = AnchorKind::Smooth;
}
_ if !smooth => a.retract(),
Some(t) if t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
Some(_) => a.retract(),
None => {
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l > 1e-9 {
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
}
sp.smooth_anchor(ai);
}
}
Ok(())
@@ -767,30 +746,13 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(EngineError::Other("no such segment".into())),
};
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
if sp.closed {
sp.anchors.rotate_left(k);
let mut last = sp.anchors[0];
last.h_out = last.p;
sp.anchors[0].h_in = sp.anchors[0].p;
last.kind = AnchorKind::Corner;
sp.anchors[0].kind = AnchorKind::Corner;
sp.anchors.push(last);
sp.closed = false;
path.subpaths[si] = sp;
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
sel.set([id]);
return Ok(vec![id]);
}
let n = sp.anchors.len();
if k == 0 || k + 1 >= n {
return Err(EngineError::Other("cannot split an open path at its end point".into()));
}
let mut left = SubPath::new(sp.anchors[..=k].to_vec(), false);
let mut right = SubPath::new(sp.anchors[k..].to_vec(), false);
let lp = left.anchors[k].p;
left.anchors[k].h_out = lp;
left.anchors[k].kind = AnchorKind::Corner;
right.anchors[0].h_in = lp;
right.anchors[0].kind = AnchorKind::Corner;
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
if path.subpaths.len() == 1 {
path.subpaths[0] = left;
let node = sibling_with(d, id, PathData::single(right))?;
+33 -11
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_geom::Affine;
use vectorcraft_geom::{Affine, Vec2};
use super::clipboard::SwatchChoices;
use super::*;
@@ -39,14 +39,14 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, copy),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} (the Artboard tool chosen: artboard.copy)", has_selection_or_artboard_tool, copy),
cmd!(
"edit.paste",
"Paste",
["Edit"],
Some("Cmd+V"),
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing) → {ids, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Offset)
),
@@ -237,12 +237,19 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
}
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
// The Artboard tool copies its artboard (and the art on it).
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &json!({}), false);
}
let roots = selected_roots(s)?;
s.clipboard = Clipboard::copy(s.doc()?, &roots);
Ok(json!({ "copied": s.clipboard.nodes.len() }))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &json!({}), true);
}
copy(s, p)?;
clear(s, &json!({}))
}
@@ -308,8 +315,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let st = s.doc()?;
let same_doc = clip.source_doc == Some(st.uid);
let parent = st.insertion_parent();
// Paste Remembers Layers (not while isolating a group: pastes stay in it).
let remember = st.doc.paste_remembers_layers && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
// A copied artboard comes back with its art: right of the last artboard, or where it was
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
let board = clip
.artboard
.as_ref()
.filter(|_| mode != PasteMode::AllArtboards)
.map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } else { Vec2::ZERO }));
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
// always goes back into its layers.
let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
// Front/back: relative to the selection (top-most / bottom-most selected object); with
// nothing selected, the top / bottom of the current layer.
let anchor = match mode {
@@ -320,19 +335,21 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
}
_ => None,
};
let placements: Vec<Affine> = match mode {
PasteMode::Offset => vec![match point_param(p, "center") {
let placements: Vec<Affine> = match (mode, board) {
(_, Some((_, dv))) => vec![Affine::translate(dv)],
(PasteMode::Offset, None) => vec![match point_param(p, "center") {
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
}],
PasteMode::AllArtboards => {
(PasteMode::AllArtboards, None) => {
let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default();
st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect()
}
_ => vec![Affine::IDENTITY],
};
let (ids, imported) = s.edit(mode.label(), |d, sel| {
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
let imported = clip.import_into(d, choices, same_doc);
let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv));
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
// Objects pasted into each parent so far (keeps their order in front and back pastes).
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
@@ -364,10 +381,15 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
}
}
sel.set(new_ids.iter().copied());
Ok((new_ids, imported))
Ok((new_ids, imported, artboard))
})?;
// The Artboard tool takes the pasted artboard.
if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") {
s.set_tool_option("active", &json!(i));
}
Ok(json!({
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
"artboard": artboard,
"added": imported.added,
"merged": imported.merged,
"renamed": imported.renamed,
@@ -92,6 +92,22 @@ fn postscript_ai_files_and_garbage() {
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert_eq!(objects(&s), 1);
// Illustrator's groups come in as groups, nested as they were.
let ai = b"%!PS-Adobe-3.0
%%Creator: Adobe Illustrator(R) 8.0
%%BoundingBox: 0 0 100 100
%%EndComments
/u {} def /U {} def /L {lineto} def /f {closepath fill} def
u 0 0 moveto 9 0 L 9 9 L f u 20 0 moveto 29 0 L 29 9 L f 40 0 moveto 49 0 L 49 9 L f U U
showpage
%%EOF
";
open(&mut s, "groups.ai", ai).unwrap();
let d = s.doc().unwrap().doc.clone();
let outer = &d.layers[0].children().unwrap()[0];
let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap();
assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind);
assert_eq!(inner, (2, Some(2)));
// A .ait one opens as a new untitled document.
let r = open(&mut s, "legacy.ait", ps).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}");
+10 -2
View File
@@ -735,14 +735,16 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
s.edit("Delete Artboard", |d, _| {
s.edit("Delete Artboard", |d, sel| {
if d.artboards.len() <= 1 {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
}
d.artboards.remove(i);
// Its guides go with it.
let id = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(id));
Ok(())
})?;
ok()
@@ -752,10 +754,16 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let was = a.rect;
a.rect = rect_from(p, a.rect);
if let Some(n) = str_param(p, "name") {
a.name = n.to_string();
}
// Moved (not resized), it takes its guides along.
let (id, now) = (a.id, a.rect);
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
d.move_artboard_guides(id, now.origin() - was.origin());
}
Ok(())
})?;
ok()
+45 -1
View File
@@ -1,5 +1,5 @@
//! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset
//! Bounding Box, Rasterize, Crop Image, Create Trim Marks, Convert to Shape and
//! Bounding Box, Rasterize, Crop Image, Mask (an image), Create Trim Marks, Convert to Shape and
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
use std::sync::Arc;
@@ -115,6 +115,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_image,
crop_image
),
cmd!(
"object.maskImage",
"Mask",
[],
None,
"{} clip the selected image with a rectangle around it (its own outline, rotated with it) and select that clipping path, whose handles then crop it; one undo step → {id: the clip group, path: the clipping path}",
has_image,
mask_image
),
cmd!(
"object.createTrimMarks",
"Create Trim Marks",
@@ -585,6 +594,36 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "width": w, "height": h }))
}
/// The Control bar's Mask for an image: a clip group of the image and a clipping path on its
/// outline, with that path selected so dragging its handles crops the image.
fn mask_image(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (id, outline) = st
.selection
.objects
.iter()
.find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Image(im)) => {
Some((*id, vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf)))
}
_ => None,
})
.ok_or_else(|| bad("object.maskImage", "select an image"))?;
let (gid, pid) = s.edit("Mask", |d, sel| {
let mut clip = super::pathops::shape_node(d, outline, None);
super::object::as_clipping_path(&mut clip)?;
let pid = clip.id;
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?;
d.insert(Some(gid), 0, clip)?;
d.move_node(id, Some(gid), usize::MAX)?;
sel.set([pid]);
Ok((gid, pid))
})?;
Ok(json!({ "id": gid.0, "path": pid.0 }))
}
// ---------- Trim marks ----------
impl Session {
@@ -769,8 +808,13 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let mut moved = vec![];
for (a, dl) in d.artboards.iter_mut().zip(&deltas) {
a.rect = a.rect + *dl;
moved.push((a.id, *dl));
}
for (id, dl) in moved {
d.move_artboard_guides(id, dl);
}
Ok(())
})?;
+4
View File
@@ -144,6 +144,10 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
}
/// Objects are selected, or the Artboard tool is chosen (Copy and Cut take its artboard).
pub fn has_selection_or_artboard_tool(s: &Session) -> std::result::Result<(), String> {
if s.tool_id() == "artboard" { has_doc(s) } else { has_selection(s) }
}
/// Objects or ruler guides are selected (what Delete and the arrow keys act on).
pub fn has_selection_or_guides(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
+3
View File
@@ -1038,6 +1038,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
// Radii are document lengths and corners circular, also on a rectangle from a file
// that kept an uneven scale in its transform (#442).
live.fold_scale();
let partial = sel.anchors.get_mut(id);
let picked = corners.unwrap_or_else(|| live.picked_corners(partial.as_deref()));
let (layout, selected) = (live.anchor_corners(), partial.as_deref().map(|a| live.corners_of(a)));
+133 -17
View File
@@ -1,11 +1,12 @@
//! Commands the panels need beyond the core set: artboard reorder/duplicate and the advanced
//! Commands the panels need beyond the core set: artboard reorder, duplicate, copy and cut, and the advanced
//! Character/Paragraph attributes.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Rect, Vec2};
use super::*;
use crate::EngineError;
use crate::{Clipboard, EngineError};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -15,16 +16,34 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Artboard",
["Window", "Artboards"],
None,
"{index} copy placed right of the last artboard",
"{index?: n (default: the Artboard tool's artboard), art?: bool (default: the Artboard tool's moveArt option)} copy placed right of the last artboard, with copies of the art fully inside it when `art` (locked and hidden art only with prefs moveLockedWithArtboard) → {index, ids: the art's copies}",
has_doc,
artboard_duplicate
),
cmd!(
"artboard.copy",
"Copy Artboard",
[],
None,
"{index?, art?} (defaults as artboard.duplicate) put the artboard, and the art fully inside it when `art`, on the clipboard: edit.paste adds a copy right of the last artboard, edit.pasteInPlace/InFront/InBack where it was, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, false)
),
cmd!(
"artboard.cut",
"Cut Artboard",
[],
None,
"{index?, art?} artboard.copy, then delete the artboard and that art in one undo step (not the only artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, true)
),
cmd!(
"text.setFormat",
"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)}",
"{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\")}",
has_doc,
set_format
),
@@ -49,24 +68,108 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = s.edit("Duplicate Artboard", |d, _| {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
let dx = right + 20.0 - src.rect.x0;
Ok(push_artboard_copy(d, &src, vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1)))
})?;
Ok(json!({"index": index}))
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
let t = s.tool_options_of("artboard");
let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize;
(i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))
}
/// Add a copy of artboard `src` at `rect`, named `<name> copy` (`<name> copy 2`… when taken),
/// with an id of its own → its index.
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: vectorcraft_geom::Rect) -> usize {
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
}
/// How far a copy of `rect` moves to sit right of the last artboard (Duplicate Artboards, Paste).
pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec2 {
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
Vec2::new(right + 20.0 - rect.x0, 0.0)
}
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let art = artboard_art(s, i, art)?;
let scale_strokes = s.prefs.scale_strokes;
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
let dv = d.artboards.get(i).map(|a| beside_artboards(d, a.rect)).unwrap_or_default();
copy_artboard(d, i, dv, &art, scale_strokes)
})?;
Ok(json!({"index": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
/// then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let art = artboard_art(s, i, art)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= 1 {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
let board = st.doc.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let clip = Clipboard { source_artboard: Some(board.rect), artboard: Some(board), ..Clipboard::copy(st, &art) };
let copied = clip.nodes.len();
if cut {
s.edit("Cut Artboard", |d, sel| {
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
}
// Its guides go with it.
let gone = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
for id in &art {
// Always there: none of the art is inside another of it.
let _ = d.remove(*id);
}
sel.prune(d);
Ok(())
})?;
if s.tool_id() == "artboard" {
s.set_tool_option("active", &json!(i.saturating_sub(1)));
}
}
s.clipboard = clip;
Ok(json!({"copied": copied}))
}
/// Add a copy of artboard `i` moved by `dv`, with copies of its guides and of its `art` (each just
/// above its original) moved along → (the copy's index, the art's copies). Duplicate Artboards and
/// Alt-drag.
pub(crate) fn copy_artboard(
d: &mut vectorcraft_doc::Document,
i: usize,
dv: Vec2,
art: &[NodeId],
scale_strokes: bool,
) -> Result<(usize, Vec<NodeId>)> {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let index = push_artboard_copy(d, &src, src.rect + dv);
// Its guides come along.
if let Some(id) = d.artboards.get(index).map(|a| a.id) {
d.copy_artboard_guides(src.id, id, dv);
}
let mut copies = Vec::with_capacity(art.len());
for id in art {
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
let mut c = d.reid(&n);
c.transform(Affine::translate(dv), scale_strokes);
copies.push(d.insert(parent, at + 1, c)?);
}
Ok((index, copies))
}
/// Add a copy of artboard `src` at `rect`, with an id of its own and its name, or `<name> copy`
/// (`<name> copy 2`…) when that is taken → its index.
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: Rect) -> usize {
let mut a = src.clone();
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0).saturating_add(1);
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
a.name = std::iter::once(format!("{} copy", src.name))
a.name = [src.name.clone(), format!("{} copy", src.name)]
.into_iter()
.chain((2u64..).map(|i| format!("{} copy {i}", src.name)))
.find(|name| !taken(name))
.unwrap_or_default();
@@ -115,6 +218,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"direction",
"leadingModel",
"charAlign",
"burasagari",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Err(bad(C, "nothing to change"));
@@ -146,6 +250,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "`leadingModel` must be \"romanBaseline\" or \"emBoxTop\"")),
}),
};
let burasagari = match p.get("burasagari") {
None => None,
Some(v) => Some(match v.as_str() {
Some("none") => vectorcraft_doc::Burasagari::None,
Some("standard") => vectorcraft_doc::Burasagari::Standard,
Some("forced") => vectorcraft_doc::Burasagari::Forced,
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
}),
};
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -213,6 +326,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = leading_model {
para.leading_model = v;
}
if let Some(v) = burasagari {
para.burasagari = v;
}
super::typecmd::refresh_bounds(t);
}
Ok(())
+67 -66
View File
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
use super::*;
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Anchor Points",
[],
None,
"{to: \"corner\"|\"smooth\"} (Control bar convert buttons)",
"{to: \"corner\"|\"smooth\"} convert the direct-selected anchors (all anchors of fully selected paths) as one undo step: corner retracts both handles, smooth pulls handles in line with the neighbours (smooth anchors keep theirs)",
has_selection,
convert_anchors
),
@@ -104,7 +104,15 @@ pub fn specs() -> Vec<CommandSpec> {
reshape
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
cmd!("path.cutAtAnchors", "Cut Path at Selected Anchor Points", [], None, "{}", has_selection, cut_at_anchors),
cmd!(
"path.cutAtAnchors",
"Cut Path at Selected Anchor Points",
[],
None,
"{} cut the paths at their direct-selected anchors (one undo step): a closed path opens there, an open one becomes one path per piece; one of each cut's two coincident anchors stays selected → {ids}",
has_anchors,
cut_at_anchors
),
]
}
@@ -386,41 +394,24 @@ fn average(s: &mut Session, p: &Value) -> Result<Value> {
}
fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchors";
let smooth = match str_param(p, "to") {
Some("smooth") => true,
Some("corner") => false,
_ => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")),
};
let t = anchor_targets(s)?;
let smooth = str_param(p, "to") == Some("smooth");
if t.is_empty() {
return Err(bad(C, "select paths or anchor points"));
}
s.edit("Convert Anchor Points", |d, _| {
for (id, v) in &t {
let path = path_mut(d, *id)?;
for &(si, ai) in v {
let sp = &path.subpaths[si];
let n = sp.anchors.len();
let prev = if ai > 0 {
Some(sp.anchors[ai - 1].p)
} else if sp.closed {
Some(sp.anchors[n - 1].p)
} else {
None
};
let next = if ai + 1 < n {
Some(sp.anchors[ai + 1].p)
} else if sp.closed {
Some(sp.anchors[0].p)
} else {
None
};
let a: &mut Anchor = &mut path.subpaths[si].anchors[ai];
let Some(sp) = path.subpaths.get_mut(si) else { continue };
if smooth {
// Handles parallel to prev→next, a third of the neighbour distances.
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l > 1e-9 {
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
}
} else {
sp.smooth_anchor(ai);
} else if let Some(a) = sp.anchors.get_mut(ai) {
a.retract();
}
}
@@ -519,40 +510,50 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
}
fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let sel = s.doc()?.selection.anchors.clone();
s.edit("Cut Path", |d, _| {
for (id, set) in &sel {
let path = path_mut(d, *id)?;
let mut out = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
let cuts: Vec<usize> = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect();
if cuts.is_empty() {
out.push(sp.clone());
continue;
}
let mut anchors = sp.anchors.clone();
if sp.closed {
anchors.rotate_left(cuts[0]);
anchors.push(anchors[0]);
}
let base = if sp.closed { cuts[0] } else { 0 };
let n = sp.anchors.len();
let rel: Vec<usize> = cuts.iter().map(|c| (c + n - base) % n).collect();
let mut cur = vec![];
for (i, a) in anchors.iter().enumerate() {
cur.push(*a);
if rel.contains(&(i % n)) && cur.len() > 1 {
out.push(SubPath::new(std::mem::take(&mut cur), false));
cur.push(*a);
}
}
if cur.len() > 1 {
out.push(SubPath::new(cur, false));
}
}
path.subpaths = out;
const C: &str = "path.cutAtAnchors";
let st = s.doc()?;
// Each selected path's subpaths cut at its selected anchors, those that cut anything.
let mut cuts: Vec<(NodeId, Vec<SubPath>, Vec<bool>)> = vec![];
for (id, set) in &st.selection.anchors {
let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let (mut subs, mut starts) = (vec![], vec![]);
for (si, sp) in pd.subpaths.iter().enumerate() {
let at: BTreeSet<usize> = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect();
let pieces = sp.cut_at(&at);
// The pieces that start at a cut: every piece of an opened closed subpath, all but the
// first of an open one.
starts.extend(pieces.iter().enumerate().map(|(k, piece)| if sp.closed { !piece.closed } else { k > 0 }));
subs.extend(pieces);
}
Ok(())
if starts.contains(&true) {
cuts.push((*id, subs, starts));
}
}
if cuts.is_empty() {
return Err(bad(C, "direct-select anchor points to cut at (not the end points of open paths)"));
}
let ids = s.edit("Cut Path", |d, sel| {
let (mut all, mut picked) = (vec![], vec![]);
for (id, subs, starts) in cuts {
// A path of one subpath becomes one path per piece (the first keeps the id); the
// pieces of a compound shape stay subpaths of it.
if d.node(id).and_then(|n| n.path_data()).is_some_and(|pd| pd.subpaths.len() == 1) {
let ids = super::draw2::replace_with_pieces(d, id, subs.into_iter().map(PathData::single).collect())?;
picked.extend(ids.iter().zip(&starts).filter(|(_, s)| **s).map(|(id, _)| (*id, (0, 0))));
all.extend(ids);
} else {
path_mut(d, id)?.subpaths = subs;
picked.extend(starts.iter().enumerate().filter(|(_, s)| **s).map(|(si, _)| (id, (si, 0))));
all.push(id);
}
}
// Each cut leaves one of its two coincident anchors selected, so a drag pulls the ends apart.
sel.clear();
for (id, a) in picked {
sel.add(id);
sel.anchors.entry(id).or_default().insert(a);
}
Ok(all)
})?;
ok()
Ok(super::draw2::ids_json(&ids))
}
+30
View File
@@ -413,6 +413,36 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
mod area_tests {
use super::*;
#[test]
fn burasagari_is_set_per_paragraph_and_saved_only_when_on() {
use vectorcraft_doc::Burasagari;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "雅楽、笙。", "area": {"width": 200, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.para.clone(),
_ => panic!("text"),
};
// New type: Standard, as in Illustrator.
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());
s.execute("text.setFormat", &json!({"burasagari": "forced"})).unwrap();
assert_eq!(para(&s).burasagari, Burasagari::Forced);
assert_eq!(serde_json::to_value(para(&s)).unwrap()["burasagari"], "forced");
s.execute("text.setFormat", &json!({"burasagari": "none"})).unwrap();
assert_eq!(para(&s).burasagari, Burasagari::None);
// Saved only when on; documents from before it read as None.
assert!(serde_json::to_value(para(&s)).unwrap().get("burasagari").is_none());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s).burasagari, Burasagari::Standard);
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
assert_eq!(old.burasagari, Burasagari::None);
}
#[test]
fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() {
use vectorcraft_doc::LeadingModel;
+11 -22
View File
@@ -371,35 +371,24 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let move_art = bool_or(p, "moveArt", false);
let st = s.doc()?;
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let art = if move_art { st.doc.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] };
let art = super::panelcmds::artboard_art(s, i, bool_or(p, "moveArt", false))?;
let scale_strokes = s.prefs.scale_strokes;
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if !copy {
if let Some(a) = d.artboards.get_mut(i) {
a.rect = src.rect + dv;
}
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
return Ok((i, art.clone()));
if copy {
return super::panelcmds::copy_artboard(d, i, dv, &art, scale_strokes);
}
let index = super::panelcmds::push_artboard_copy(d, &src, src.rect + dv);
let mut copies = Vec::with_capacity(art.len());
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
let id = a.id;
d.move_artboard_guides(id, dv);
for id in &art {
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
let mut c = d.reid(&n);
c.transform(Affine::translate(dv), scale_strokes);
copies.push(d.insert(parent, at + 1, c)?);
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
Ok((index, copies))
Ok((i, art.clone()))
})?;
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+4 -1
View File
@@ -624,7 +624,7 @@ impl Default for Prefs {
grid_subdivisions: 8,
grids_in_back: true,
show_pixel_grid: true,
smart_guide_color: s("#ff4af0"),
smart_guide_color: s("#ff3dfc"),
alignment_guides: true,
object_highlighting: true,
transform_tools_guides: true,
@@ -774,6 +774,8 @@ pub struct Session {
pub(crate) liquify_stroke: Option<Box<cmd::distortcmds::LiquifyStroke>>,
/// A press on the Plane Switching Widget is under way: its drag and release are the widget's.
pub(crate) plane_widget_press: bool,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
}
impl Default for Session {
@@ -818,6 +820,7 @@ impl Session {
envelope_defaults: None,
liquify_stroke: None,
plane_widget_press: false,
ruler_guide: None,
}
}
+63
View File
@@ -320,6 +320,69 @@ fn shape_builder_tool_alt_deletes_line_pieces() {
assert_eq!(layer_children(&s), 3, "square + the line's two loose ends");
}
/// Red square A (0..100) and blue square B (50..150 both ways), overlapping at a corner, selected.
fn corner(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let b = rect(s, 50.0, 50.0, 100.0, 100.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
set_fill(s, b, Color::rgb(0.0, 0.0, 1.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
(a, b)
}
/// (closed, anchors, length) of each of `ids`.
fn shapes_of(s: &Session, ids: &[NodeId]) -> Vec<(bool, usize, f64)> {
ids.iter()
.map(|i| {
let (p, _) = outline(&node(s, *i));
(p.is_closed(), p.anchor_count(), (p.length() * 1e6).round() / 1e6)
})
.collect()
}
#[test]
fn shape_builder_erase_click_on_an_edge_opens_its_path() {
let mut s = session();
corner(&mut s);
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(r["edges"].as_array().unwrap().len(), 4, "each outline cut in two where the other crosses it: {r}");
// A's side inside B (1 pt off it).
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["lines"].as_u64(), r["edges"].as_u64()), (Some(0), Some(0), Some(1)));
let out = ids(&r);
// A opens where B's outline crosses it and keeps its look; B is untouched.
assert_eq!(shapes_of(&s, &out), vec![(false, 5, 300.0), (true, 4, 400.0)]);
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
s.execute("edit.undo", &json!({})).unwrap();
let back: Vec<NodeId> = s.doc().unwrap().doc.children(s.doc().unwrap().doc.default_layer()).unwrap().iter().map(|n| n.id).collect();
assert_eq!(shapes_of(&s, &back), vec![(true, 4, 400.0); 2], "one undo step");
}
#[test]
fn shape_builder_erase_drag_along_edges_deletes_each() {
let mut s = session();
corner(&mut s);
// Down A's side inside B, round the corner and along B's side inside A.
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 75], [100, 50], [75, 50]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(0), Some(2)));
assert_eq!(shapes_of(&s, &ids(&r)), vec![(false, 5, 300.0); 2]);
}
#[test]
fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses() {
let mut s = session();
corner(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 25], [75, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(2), Some(0)));
// A is gone; what is left of B stays closed and blue.
let out = ids(&r);
assert_eq!(out.len(), 1);
assert!((area(&s, out[0]) - 7500.0).abs() < 1e-6);
assert!(outline(&node(&s, out[0])).0.is_closed());
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
}
// ---------- Live Paint ----------
fn live(s: &mut Session) -> NodeId {
+125
View File
@@ -534,3 +534,128 @@ fn remove_anchor_points_keeps_the_paths_closed() {
let spec = find_command("path.removeAnchors").unwrap();
assert_eq!(spec.menu, &["Object", "Path"][..]);
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn convert_anchors_turns_the_selected_anchors_corner_or_smooth() {
// The Control bar's convert buttons: one undo step each, other anchors left alone.
let mut s = session();
let made = s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 200}, {"x": 200, "y": 100}, {"x": 300, "y": 200}]})).unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
let undo = undo_steps(&s);
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
let a = path(&s, id).subpaths[0].anchors.clone();
assert_eq!(undo_steps(&s), undo + 1);
assert_eq!(a[1].kind, vectorcraft_geom::AnchorKind::Smooth);
let r = 100.0 * 2f64.sqrt() / 3.0;
assert!(near(a[1].h_in, Point::new(200.0 - r, 100.0)) && near(a[1].h_out, Point::new(200.0 + r, 100.0)), "{:?}", a[1]);
assert!(!a[0].has_out() && !a[2].has_in(), "the neighbours keep theirs");
// Smooth again keeps the handles; corner retracts them.
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 1, "which": "out", "x": 260, "y": 100})).unwrap();
let h = path(&s, id).subpaths[0].anchors[1];
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[1], h);
s.execute("path.convertAnchors", &json!({"to": "corner"})).unwrap();
let a = path(&s, id).subpaths[0].anchors[1];
assert!(!a.has_in() && !a.has_out(), "{a:?}");
// Anchors that are gone (a stale selection) are skipped; a bad `to` is refused.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 99], [7, 0]]})).unwrap();
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
let undo = undo_steps(&s);
assert!(s.execute("path.convertAnchors", &json!({"to": "round"})).is_err());
assert_eq!(undo_steps(&s), undo);
}
#[test]
fn cut_at_anchors_splits_paths_and_leaves_one_end_selected() {
// Cut Path at Selected Anchor Points: an open path becomes one path per piece, and one anchor
// of each cut stays selected, so dragging it pulls the path apart there.
let mut s = session();
let anchors = json!([{"x": 100, "y": 100}, {"x": 200, "y": 100, "in": [170, 80], "out": [230, 120]}, {"x": 300, "y": 100}, {"x": 400, "y": 100}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 2]]})).unwrap();
let undo = undo_steps(&s);
let made = s.execute("path.cutAtAnchors", &json!({})).unwrap();
let ids: Vec<NodeId> = made["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(undo_steps(&s), undo + 1);
assert_eq!((ids.len(), ids[0]), (3, id));
let pieces: Vec<Vec<Point>> = ids.iter().map(|i| path(&s, *i).subpaths[0].anchors.iter().map(|a| a.p).collect()).collect();
let p = |x: f64| Point::new(x, 100.0);
assert_eq!(pieces, vec![vec![p(100.0), p(200.0)], vec![p(200.0), p(300.0)], vec![p(300.0), p(400.0)]]);
// The curve keeps its shape: each end keeps the handle on its own side only.
let (l, r) = (path(&s, ids[0]).subpaths[0].anchors[1], path(&s, ids[1]).subpaths[0].anchors[0]);
assert!(l.h_in == Point::new(170.0, 80.0) && !l.has_out() && r.h_out == Point::new(230.0, 120.0) && !r.has_in(), "{l:?} {r:?}");
let sel = s.doc().unwrap().selection.clone();
assert_eq!(sel.objects, vec![ids[1], ids[2]]);
assert!(sel.anchors.values().all(|a| a.iter().eq([&(0, 0)])), "{sel:?}");
s.execute("path.moveAnchors", &json!({"dx": 0, "dy": 50})).unwrap();
assert_eq!(path(&s, ids[0]).subpaths[0].anchors[1].p, p(200.0));
assert_eq!(path(&s, ids[1]).subpaths[0].anchors[0].p, Point::new(200.0, 150.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paths(&s).len(), 1);
// A closed path opens at the cut, its ends on top of each other.
let r = rect(&mut s, 500.0, 100.0, 100.0, 100.0);
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 2]]})).unwrap();
s.execute("path.cutAtAnchors", &json!({})).unwrap();
let sp = &path(&s, r).subpaths[0];
assert!(!sp.closed && sp.anchors.len() == 5 && sp.anchors[0].p == sp.anchors[4].p, "{sp:?}");
assert_eq!(s.doc().unwrap().selection.anchors[&r].iter().collect::<Vec<_>>(), [&(0, 0)]);
// Only the end points of an open path selected: nothing to cut.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 3]]})).unwrap();
let undo = undo_steps(&s);
assert!(s.execute("path.cutAtAnchors", &json!({})).is_err());
assert_eq!(undo_steps(&s), undo);
}
#[test]
fn pen_with_alt_moves_one_handle_and_converts_anchors() {
// Discord feedback: Alt held with the Pen moves a direction handle on its own (and works as
// the Anchor Point tool on a selected path's anchors), while a path is being drawn too.
let alt = Mods { alt: true, ..Mods::default() };
let v = view();
let mut s = session();
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
let gesture = |s: &mut Session, pts: &[(f64, f64)], m: Mods| {
let undo = undo_steps(s);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
undo_steps(s) - undo
};
// Alt-drag the out handle: it moves alone, the in handle stays.
assert_eq!(gesture(&mut s, &[(250.0, 100.0), (260.0, 130.0), (275.0, 165.0)], alt), 1);
assert!(!s.in_interaction());
let a = path(&s, id).subpaths[0].anchors[1];
assert_eq!((a.h_in, a.h_out), (Point::new(150.0, 100.0), Point::new(275.0, 165.0)));
assert_eq!(paths(&s).len(), 1, "no new path or anchor");
// Alt-click the anchor: a corner. Alt-drag it: new symmetric handles.
assert_eq!(gesture(&mut s, &[(200.0, 100.0)], alt), 1);
let a = path(&s, id).subpaths[0].anchors[1];
assert!(!a.has_in() && !a.has_out(), "{a:?}");
gesture(&mut s, &[(200.0, 100.0), (220.0, 100.0), (240.0, 110.0)], alt);
let a = path(&s, id).subpaths[0].anchors[1];
assert_eq!((a.h_in, a.h_out), (Point::new(160.0, 90.0), Point::new(240.0, 110.0)));
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
// Drawing: place a smooth point, then Alt-drag its outgoing handle; the next click goes on
// drawing the same path.
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, &[(100.0, 400.0)], Mods::default());
gesture(&mut s, &[(200.0, 400.0), (250.0, 400.0)], Mods::default());
let new = *s.doc().unwrap().selection.objects.first().unwrap();
gesture(&mut s, &[(250.0, 400.0), (230.0, 350.0)], alt);
let a = path(&s, new).subpaths[0].anchors[1];
assert_eq!((a.h_in, a.h_out), (Point::new(150.0, 400.0), Point::new(230.0, 350.0)));
gesture(&mut s, &[(300.0, 450.0)], Mods::default());
assert_eq!(path(&s, new).subpaths[0].anchors.len(), 3);
assert_eq!(paths(&s).len(), 2);
}
+45
View File
@@ -5,6 +5,7 @@ use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_geom::shapes::CornerKind;
use super::*;
@@ -105,3 +106,47 @@ fn a_drag_previews_one_corner_and_commits_one_step() {
assert_eq!(selected_anchors(&s, id), Some(anchors(&[2, 3])));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
}
/// How far each corner's cut reaches along x and y (top-left, top-right, bottom-right,
/// bottom-left) of a rectangle with four cut corners: equal for a circular arc.
fn spans(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") };
let a: Vec<_> = path.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(a.len(), 8, "four cut corners");
[(7, 0), (1, 2), (3, 4), (5, 6)].iter().map(|(i, j)| ((a[*j].x - a[*i].x).abs(), (a[*j].y - a[*i].y).abs())).collect()
}
fn assert_spans(s: &Session, id: NodeId, want: [f64; 4]) {
let got = spans(s, id);
let ok = got.iter().zip(want).all(|((x, y), r)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9);
assert!(ok, "corners span {got:?}, want circles of radii {want:?}");
}
/// #442: on a rectangle that isn't square, every corner is the same circle, also past the limit
/// and on a shape from a file that kept an uneven scale in its transform (before #291).
#[test]
fn corners_of_a_non_square_rectangle_are_alike_circles() {
let (mut s, id) = session();
run(&mut s, json!({"radius": 12}));
assert_spans(&s, id, [12.0; 4]);
// Past half the shorter side, every corner stops there.
run(&mut s, json!({"radius": 45}));
assert_spans(&s, id, [30.0; 4]);
// A 50 × 60 rectangle with 10 pt corners, saved stretched to 100 × 60: 20 × 10 ellipses.
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") };
let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0);
*live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
*path = live.to_path();
}
assert_eq!(spans(&s, id)[0], (20.0, 10.0));
// Rounding one corner makes every corner a circle: the others of the mean radius.
run(&mut s, json!({"corners": [1], "radius": 12}));
let mean = 10.0 * 2f64.sqrt();
assert_spans(&s, id, [mean, 12.0, mean, mean]);
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-9 && (b.x1 - 110.0).abs() < 1e-9 && (b.y1 - 70.0).abs() < 1e-9, "same bounds: {b:?}");
run(&mut s, json!({"radius": 8}));
assert_spans(&s, id, [8.0; 4]);
}
+144
View File
@@ -234,6 +234,30 @@ fn crop_image_disabled_without_image() {
assert!(matches!(s.execute("object.cropImage", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn mask_image_clips_it_to_its_outline_and_selects_the_clipping_path() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 100.0, 50.0);
sel(&mut s, &[a]);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let ib = bounds(&s, img);
let r = s.execute("object.maskImage", &json!({})).unwrap();
let (g, p) = (id_of(&r), NodeId(r["path"].as_u64().unwrap()));
let group = node(&s, g);
assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }));
assert_eq!(group.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [p, img]);
// The clipping path follows the rotated image's outline, so nothing is cut off yet.
assert!(matches!(node(&s, p).kind, NodeKind::Path { clipping: true, .. }));
let pb = bounds(&s, p);
assert!(close(pb.x0, ib.x0) && close(pb.y0, ib.y0) && close(pb.x1, ib.x1) && close(pb.y1, ib.y1), "{pb:?} {ib:?}");
assert_eq!(s.doc().unwrap().selection.objects, [p]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none() && s.doc().unwrap().doc.node(img).is_some());
sel(&mut s, &[]);
assert!(matches!(s.execute("object.maskImage", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn trim_marks_around_selection() {
let mut s = session();
@@ -799,6 +823,126 @@ fn ruler_guides_select_move_and_delete() {
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
}
/// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved,
/// copied and deleted with it; canvas guides stay put.
#[test]
fn artboard_guides_go_with_their_artboard() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
assert_eq!(
list(&mut s),
json!([
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1},
{"index": 1, "vertical": false, "pos": 100.0, "selected": false},
])
);
let spans = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.guides.iter().map(|g| d.guide_span(g)).collect::<Vec<_>>()
};
assert_eq!(spans(&s), [Some((0.0, 300.0)), None]);
for bad in [json!(5), json!("x"), json!(-1)] {
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10, "artboard": bad})).is_err(), "{bad}");
}
// Moved, the artboard takes its guides along; in Artboard Options too, unless resized.
s.execute("artboard.move", &json!({"index": 1, "dx": 50, "dy": 20})).unwrap();
assert_eq!(guides(&s), [(true, 1050.0), (false, 100.0)]);
s.execute("artboard.setProps", &json!({"index": 1, "x": 960.5})).unwrap();
assert_eq!(guides(&s)[0], (true, 1060.5));
s.execute("artboard.setProps", &json!({"index": 1, "x": 900, "width": 500})).unwrap();
assert_eq!((guides(&s)[0], spans(&s)[0]), ((true, 1060.5), Some((20.0, 320.0))));
// Renumbered, it keeps them.
s.execute("artboard.reorder", &json!({"index": 1, "to": 0})).unwrap();
assert_eq!(list(&mut s)[0]["artboard"], 0);
// Duplicated (or Alt-dragged), the copy gets copies of them.
let dup = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap()["index"].as_u64().unwrap();
let dx = s.doc().unwrap().doc.artboards[dup as usize].rect.x0 - 900.0;
assert_eq!(guides(&s), [(true, 1060.5), (false, 100.0), (true, 1060.5 + dx)]);
assert_eq!(list(&mut s)[2]["artboard"], dup);
s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 400, "copy": true})).unwrap();
assert_eq!((guides(&s).len(), list(&mut s)[3]["artboard"].clone()), (4, json!(3)));
// Rearranged, they follow their artboards.
s.execute("artboard.rearrange", &json!({"columns": 4})).unwrap();
let d = &s.doc().unwrap().doc;
for (g, ab) in d.guides.iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) {
assert_eq!(g.pos - ab.rect.x0, 160.5, "{g:?} on {ab:?}");
}
// Deleted, the artboard takes its guides; the selected ones left stay selected.
s.execute("guide.select", &json!({"indexes": [1, 2]})).unwrap();
let n = undo_len(&s);
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true}));
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
// Cut, too.
s.execute("artboard.cut", &json!({"index": 0})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
}
/// #451: a guide dragged out of a ruler snaps (to the art's corners, side midpoints and anchors,
/// the artboards' edges and centres) and is made in one undo step where it is released over the
/// canvas; with the Artboard tool it is an artboard guide of the active artboard.
#[test]
fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
use vectorcraft_tools::{Overlay, PointerEvent, PointerKind};
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let v = ViewInfo::default();
let ev = |kind, x, y| PointerEvent::new(kind, x, y);
let n = undo_len(&s);
s.ruler_guide(true, &ev(PointerKind::Drag, -10.0, 30.0), false, v).unwrap();
assert!(guides(&s).is_empty(), "still over the ruler");
s.ruler_guide(true, &ev(PointerKind::Drag, 153.0, 30.0), true, v).unwrap();
assert_eq!(guides(&s), [(true, 150.0)], "onto the line through the top and bottom midpoints");
assert!(s.overlays(v).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "center")));
s.ruler_guide(true, &ev(PointerKind::Drag, 797.0, 30.0), true, v).unwrap();
assert_eq!(guides(&s), [(true, 800.0)], "the artboard's edge");
s.ruler_guide(true, &ev(PointerKind::Up, 797.0, 30.0), true, v).unwrap();
assert_eq!(guides(&s), [(true, 800.0)]);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "New Guide");
assert!(s.overlays(v).is_empty());
// Released off the canvas: none.
s.ruler_guide(false, &ev(PointerKind::Drag, 30.0, 250.0), true, v).unwrap();
s.ruler_guide(false, &ev(PointerKind::Up, 30.0, -10.0), false, v).unwrap();
assert_eq!((guides(&s).len(), undo_len(&s)), (1, n + 1));
// The Artboard tool: an artboard guide of its active artboard.
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap();
s.select_tool("artboard", v).unwrap();
s.set_tool_option("active", &json!(1));
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
assert_eq!(
s.execute("guide.list", &json!({})).unwrap()[1],
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1})
);
}
/// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart
/// Guides).
#[test]
fn moved_art_snaps_to_the_artboard_edges_and_centre() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let mut drag = |from: (f64, f64), to: (f64, f64)| {
for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
let b = bounds(&s, a);
(b.x0, b.y0)
};
assert_eq!(drag((150.0, 150.0), (53.0, 147.0)), (0.0, 97.0), "3 pt off the left edge: flush with it");
assert_eq!(drag((50.0, 147.0), (402.0, 298.0)), (350.0, 250.0), "its centre on the artboard's");
}
/// #414: the Selection tool picks a ruler guide over the art and drags it in one undo step; with
/// guides hidden or locked the press goes to the art.
#[test]
+103
View File
@@ -87,6 +87,109 @@ fn artboards_reorder_and_duplicate() {
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second");
}
/// The artboards' left edges and names.
fn boards(s: &Session) -> Vec<(f64, String)> {
s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect()
}
/// The left edge of object `id`.
fn left(s: &Session, id: u64) -> f64 {
s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap().x0
}
fn ids(v: &serde_json::Value) -> Vec<u64> {
v.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
/// Duplicate Artboards (#446) copies the art on the artboard too, as the Artboard tool's Move/Copy
/// Artwork with Artboard says, in one undo step.
#[test]
fn duplicate_artboard_copies_its_art() {
let mut s = session();
let r = rect(&mut s);
let out = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap();
assert_eq!(out["index"], 1);
let copies = ids(&out["ids"]);
assert_eq!(copies.len(), 1);
assert_ne!(copies[0], r.0);
assert_eq!(boards(&s), vec![(0.0, "Artboard 1".into()), (820.0, "Artboard 1 copy".into())]);
assert_eq!(left(&s, copies[0]), 830.0, "the copy sits on the new artboard as the original on its own");
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 1), "one undo step");
// Off (the tool's option or the param): the artboard alone.
s.set_tool_options(Some("artboard"), json!({"moveArt": false}).as_object().unwrap());
assert_eq!(s.execute("artboard.duplicate", &json!({})).unwrap()["ids"], json!([]));
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
}
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
/// is one undo step, and the tool takes the pasted artboard.
#[test]
fn artboard_tool_copy_and_paste_artboards_with_their_art() {
let mut s = session();
let r = rect(&mut s);
s.execute("artboard.new", &json!({"x": 0, "y": 700, "width": 100, "height": 100, "name": "Small"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
// Nothing selected: Copy is off, until the Artboard tool is chosen.
let copy_enabled = |s: &Session| (crate::find_command("edit.copy").unwrap().enabled)(s).is_ok();
assert!(!copy_enabled(&s));
s.select_tool("artboard", ViewInfo::default()).unwrap();
assert!(copy_enabled(&s));
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["copied"], 1, "artboard 1 and its rectangle");
let out = s.execute("edit.paste", &json!({"center": [5, 5]})).unwrap();
assert_eq!(out["artboard"], 2);
let pasted = ids(&out["ids"]);
assert_eq!(left(&s, pasted[0]), 830.0, "beside the artboards, not at the view centre");
assert_eq!(boards(&s)[2], (820.0, "Artboard 1 copy".into()));
assert_eq!(s.tool_options()["active"], 2);
let st = s.doc().unwrap();
assert_eq!(st.selection.in_paint_order(&st.doc), vec![NodeId(pasted[0])]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(boards(&s).len(), 2);
assert!(s.doc().unwrap().doc.node(NodeId(pasted[0])).is_none(), "one undo step");
// In place: on top of the original.
let out = s.execute("edit.pasteInPlace", &json!({})).unwrap();
assert_eq!(left(&s, ids(&out["ids"])[0]), 10.0);
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards[2].rect, d.artboards[0].rect);
// Into another document.
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let out = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(boards(&s), vec![(0.0, "Artboard 1".into()), (320.0, "Artboard 1 copy".into())]);
let n = s.doc().unwrap().doc.node(NodeId(ids(&out["ids"])[0])).unwrap().geometric_bounds().unwrap();
assert_eq!((n.x0, n.width()), (330.0, 100.0));
// Paste on All Artboards pastes the art alone.
let out = s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap();
assert_eq!((out["artboard"].clone(), ids(&out["ids"]).len(), boards(&s).len()), (json!(null), 2, 2));
// The clipboard still holds the artboard for the next document, and the original is untouched.
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert!(s.doc().unwrap().doc.node(r).is_some());
}
/// With the Artboard tool, Cut takes its artboard and the art on it away in one undo step (#446),
/// never the only artboard; pasting it back keeps its name.
#[test]
fn artboard_tool_cut_removes_the_artboard_and_its_art() {
let mut s = session();
let r = rect(&mut s);
let outside = s.execute("shape.rectangle", &json!({"x": 2000, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.select_tool("artboard", ViewInfo::default()).unwrap();
assert!(s.execute("edit.cut", &json!({})).is_err(), "the only artboard");
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
s.set_tool_option("active", &json!(0));
s.execute("edit.cut", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards.len(), d.node(r).is_none(), d.node(NodeId(outside)).is_some()), (1, true, true));
s.execute("edit.pasteInPlace", &json!({})).unwrap();
assert_eq!(boards(&s), vec![(1000.0, "Artboard 2".into()), (0.0, "Artboard 1".into())]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards.len(), d.node(r).is_some()), (2, true), "the cut is one undo step");
}
#[test]
fn swatch_groups_duplicate_sort() {
let mut s = session();
+76
View File
@@ -279,6 +279,82 @@ fn snap_to_point_lands_a_dragged_selection_on_anchors() {
assert_eq!(drag(&mut s, ViewInfo { snap_to_point: false, ..v }), (276.5, 76.0), "View › Snap to Point off");
}
/// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement
/// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a
/// square is dragged into line with another, and how far the pull reaches; a hidden guide still
/// snaps.
#[test]
fn smart_guide_display_preferences_filter_the_overlays() {
use vectorcraft_tools::Overlay;
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
square(&mut s, 100.0, 100.0);
let b = square(&mut s, 200.0, 100.0);
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let count = |s: &mut Session, f: &dyn Fn(&Overlay) -> bool| s.overlays(v).iter().filter(|o| f(o)).count();
let line = |o: &Overlay| matches!(o, Overlay::Line { .. });
let label = |o: &Overlay| matches!(o, Overlay::Label { .. });
let measure = |o: &Overlay| matches!(o, Overlay::Measure { .. });
// `b` grabbed by its centre and dragged so its left edge comes `off` px from the first
// square's right edge (x = 150): the x of its left edge mid-drag, before the pointer is let go.
let drag_to = |s: &mut Session, off: f64| {
gesture(s, v, &[(Down, 225.0, 125.0), (Drag, 200.0, 125.0), (Drag, 175.0 + off, 125.0)]);
top_left(s, b).0
};
let release = |s: &mut Session| {
gesture(s, v, &[(Up, 175.0, 125.0)]);
s.execute("edit.undo", &json!({})).unwrap();
};
assert_eq!(drag_to(&mut s, 3.0), 150.0, "3 px off: into line");
assert!(count(&mut s, &line) > 0 && count(&mut s, &measure) == 1);
let magenta = s.overlays(v).iter().find_map(|o| if let Overlay::Line { color, .. } = o { Some(*color) } else { None });
assert_eq!(magenta, Some(vectorcraft_tools::guides::MAGENTA), "the default colour");
release(&mut s);
set_pref(&mut s, "smartGuideColor", json!("#00ff00"));
drag_to(&mut s, 3.0);
assert!(s.overlays(v).iter().all(|o| !matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color != [0, 255, 0])));
release(&mut s);
set_pref(&mut s, "alignmentGuides", json!(false));
assert_eq!(drag_to(&mut s, 3.0), 150.0, "still into line");
assert_eq!(count(&mut s, &line), 0, "no line");
release(&mut s);
set_pref(&mut s, "measurementLabels", json!(false));
drag_to(&mut s, 3.0);
assert_eq!(count(&mut s, &measure), 0);
release(&mut s);
// Snapping Tolerance: 6 px is beyond the default 4, within 8.
assert_eq!(drag_to(&mut s, 6.0), 156.0);
release(&mut s);
set_pref(&mut s, "snappingTolerance", json!(8));
assert_eq!(drag_to(&mut s, 6.0), 150.0);
release(&mut s);
// Transform Tools: the size readout while a bounding-box handle is dragged.
let scale = |s: &mut Session| {
gesture(s, v, &[(Down, 225.0, 150.0), (Drag, 225.0, 170.0)]);
let n = count(s, &measure);
gesture(s, v, &[(Up, 225.0, 170.0)]);
s.execute("edit.undo", &json!({})).unwrap();
n
};
assert_eq!(scale(&mut s), 1);
set_pref(&mut s, "transformToolsGuides", json!(false));
assert_eq!(scale(&mut s), 0);
// Anchor/Path Labels: a drawn corner pulled onto an anchor says "anchor" (and still lands there).
s.select_tool("rectangle", v).unwrap();
let draw = |s: &mut Session| {
gesture(s, v, &[(Down, 300.0, 300.0), (Drag, 153.0, 151.0)]);
let n = count(s, &label);
gesture(s, v, &[(Up, 153.0, 151.0)]);
let at = top_left(s, s.doc().unwrap().selection.objects[0]);
s.execute("edit.undo", &json!({})).unwrap();
(n, at)
};
assert_eq!(draw(&mut s), (1, (150.0, 150.0)));
set_pref(&mut s, "anchorPathLabels", json!(false));
assert_eq!(draw(&mut s), (0, (150.0, 150.0)));
}
/// Enable Rubber Band for Pen Tool / Curvature Tool (#394): off, no segment follows the pointer.
#[test]
fn rubber_band_preferences_hide_the_preview_to_the_pointer() {
+73 -1
View File
@@ -1,6 +1,6 @@
//! Resizing area type (#252): a bounding-box handle drag and Area Type Options size the type area
//! and the text reflows at its size; Direct Selection reshapes the frame by a corner or an edge.
//! Point type still scales.
//! Point type still scales. The type widget converts point type to area type and back.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, TextKind, TextObject};
@@ -267,3 +267,75 @@ fn reshape_area_is_a_registered_command() {
assert!(cmd::find_command("object.transform").unwrap().params.contains("typeAreas"));
assert!(session().commands().iter().any(|c| c.id == "text.reshapeArea" && c.enabled));
}
/// The Discord report: type in a frame dragged with the Type tool, then the Selection tool. Its
/// bounding box resizes the type area (the text reflows at its size), it doesn't scale the type.
#[test]
fn type_in_a_dragged_frame_resizes_with_the_selection_tool() {
let mut s = session();
drag(&mut s, "type", Point::new(40.0, 40.0), Point::new(160.0, 80.0));
let v = ViewInfo::default();
s.tool_text(STORY, v).unwrap();
s.select_tool("selection", v).unwrap();
let id = s.doc().unwrap().selection.objects[0];
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
let lines = layout(&s, id).lines.len();
drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(200.0, 140.0));
assert_eq!(last_step(&s), "Resize Type Area");
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0));
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)), "the type isn't scaled");
assert!(layout(&s, id).lines.len() > lines, "the text reflows into the larger area");
}
/// The type widget beside the selected type's bounding box (as the Selection tool shows it at
/// the default view).
fn widget(s: &Session) -> vectorcraft_tools::typewidget::TypeWidget {
let st = s.doc().unwrap();
let bx = s.transform_box(&st.selection.objects).unwrap();
vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &bx, ViewInfo::default().zoom).unwrap()
}
fn double_click(s: &mut Session, p: Point) {
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
}
}
/// Double-clicking the type widget converts point type to area type and back, each one undo
/// step, keeping the text, its styles and where it stands; the selection stays.
#[test]
fn the_type_widget_converts_point_and_area_type() {
let mut s = session();
let id = id_of(&s.execute("text.create", &json!({"x": 40, "y": 100, "size": 18, "text": "Two words"})).unwrap());
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 9, "size": 24})).unwrap();
sel(&mut s, &[id]);
let before = text(&s, id);
let glyph = |t: &TextObject| t.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs[0].origin;
let w = widget(&s);
assert!(!w.area);
let n = undo_len(&s);
double_click(&mut s, w.at);
assert_eq!(undo_len(&s), n + 1, "one step");
assert_eq!(last_step(&s), "Convert To Area Type");
let t = text(&s, id);
assert!(matches!(t.kind, TextKind::Area { .. }));
assert_eq!((t.plain_text(), &t.runs), (before.plain_text(), &before.runs), "the text and its styles stay");
assert!(glyph(&t).distance(glyph(&before)) < 0.01, "the type stays where it was");
assert!(!layout(&s, id).overflow);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
// Now area type: a filled widget, and a double-click turns it back into point type.
let w = widget(&s);
assert!(w.area);
double_click(&mut s, w.at);
assert_eq!(undo_len(&s), n + 2);
assert_eq!(last_step(&s), "Convert To Point Type");
let t = text(&s, id);
assert!(matches!(t.kind, TextKind::Point));
assert_eq!(t.runs, before.runs);
assert!(glyph(&t).distance(glyph(&before)) < 0.01);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), before);
}
+42
View File
@@ -44,6 +44,14 @@ impl Default for ViewInfo {
}
}
/// The RGB of a colour preference (`#rrggbb`), or `fallback` when it doesn't parse.
fn rgb(hex: &str, fallback: [u8; 3]) -> [u8; 3] {
vectorcraft_color::Color::from_hex(hex).map_or(fallback, |c| {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
})
}
/// Requests from tools that only the frontend can fulfil.
#[derive(Clone, Debug, PartialEq)]
pub enum UiRequest {
@@ -168,6 +176,12 @@ impl Session {
pen_rubber_band: self.prefs.pen_rubber_band,
curvature_rubber_band: self.prefs.curvature_rubber_band,
placeholder_text: self.prefs.placeholder_text,
smart_guide_color: rgb(&self.prefs.smart_guide_color, vectorcraft_tools::guides::MAGENTA),
alignment_guides: self.prefs.alignment_guides,
anchor_path_labels: self.prefs.anchor_path_labels,
measurement_labels: self.prefs.measurement_labels,
transform_tools_guides: self.prefs.transform_tools_guides,
snapping_tolerance: self.prefs.snapping_tolerance,
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
@@ -206,6 +220,30 @@ impl Session {
self.apply_actions(acts)
}
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
/// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the
/// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over
/// the canvas, snapped as a moved guide is, and is made where the button is released over it
/// (one undo step); released anywhere else, none is. With the Artboard tool it is an artboard
/// guide of the active artboard.
pub fn ruler_guide(&mut self, vertical: bool, ev: &PointerEvent, on_canvas: bool, view: ViewInfo) -> Result<()> {
let g = match self.ruler_guide.take() {
Some(g) => Some(g),
None => {
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten();
let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards);
self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::NewGuide::new(cx, vertical, artboard)))
}
};
let Some(mut g) = g else { return Ok(()) };
let acts = self.with_tool_cx(view, |_, cx| g.pointer(cx, ev, on_canvas));
if ev.kind != PointerKind::Up {
self.ruler_guide = Some(g);
}
self.apply_actions(acts).map(drop)
}
/// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the
/// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event.
pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result<Vec<UiRequest>> {
@@ -273,6 +311,10 @@ impl Session {
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
if let Some(g) = self.ruler_guide.take() {
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
self.ruler_guide = Some(g);
}
if let Some(d) = self.active() {
let w = self.prefs.perspective_widget;
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
+6 -3
View File
@@ -873,10 +873,12 @@ proptest! {
survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// Hostile PostScript programs: operators in any order with any operands, opened and placed.
/// 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.
#[test]
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60)) {
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n%%EndComments\n{}\nshowpage\n", tokens.join(" "));
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" };
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" "));
survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?;
let r = catch_quiet(|| {
let mut s = rich_session();
@@ -922,6 +924,7 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 1] /Function << /FunctionType 2 /C0 [0 0 0] /C1 [1 1 1] /N 1 >> >>",
"<< /PatternType 2 /Shading << /ShadingType 3 /ColorSpace /DeviceGray /Coords [0 0 0 9 9 9] /Function << /FunctionType 3 /Functions [] /Bounds [] /Encode [] >> >> >>",
"<< /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 8 /ImageMatrix [2 0 0 2 0 0] /DataSource (abcdefghijkl) >>",
"u", "U", "u u u", "U U", "{ u } 300 repeat", "1 1 300 { pop u 0 0 9 9 rectclip } for",
])
.prop_map(str::to_string),
]
+66 -17
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use kurbo::{PathEl, Shape};
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::clipnest::{Clip, Drawn};
use vectorcraft_doc::clipnest::{Clip, Drawn, MAX_NEST, deep_clips};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
@@ -25,7 +25,7 @@ const HAIRLINE: f64 = 0.25;
const MAX_HIVAL: usize = 4095;
/// Most elements the current path may have.
const MAX_PATH: usize = 1 << 20;
/// Most path elements drawn in all.
/// Most path elements drawn in all (each object's clips and groups counting as elements too).
const MAX_DRAWN: usize = 1 << 24;
/// Samples taken of a shading function that isn't a plain interpolation.
const SHADING_SAMPLES: usize = 32;
@@ -147,8 +147,13 @@ pub(crate) struct Out {
pub page: Affine,
/// The page (the bounding box) in document space.
pub frame: Rect,
/// Clip and group ids are given in the order they open, so a chain of both sorts by id.
next_clip: u32,
/// Path elements drawn so far (see [`MAX_DRAWN`]).
/// The groups open (outermost first), and how many more opened past [`MAX_NEST`] (their art
/// goes in the innermost group kept).
groups: Vec<Arc<Clip>>,
too_deep: usize,
/// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]).
elements: usize,
/// The path the last object was filled with: a stroke of the same path right after joins it.
merge: Option<BezPath>,
@@ -175,6 +180,8 @@ impl Out {
page,
frame,
next_clip: 0,
groups: vec![],
too_deep: 0,
elements: 0,
merge: None,
cmyk: 0,
@@ -190,10 +197,48 @@ impl Out {
}
}
/// Add `node` under `clips`; `None` when it was left out.
/// Open a group: what is drawn until it ends ([`Self::end_group`]) goes in it.
pub fn begin_group(&mut self) {
if self.groups.len() >= MAX_NEST {
self.too_deep += 1;
self.warn(&format!("groups nested more than {MAX_NEST} deep were read as part of the group around them"));
return;
}
self.next_clip += 1;
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None }));
}
/// End the innermost group open (an end without a beginning ends nothing).
pub fn end_group(&mut self) {
if self.too_deep > 0 {
self.too_deep -= 1;
} else {
self.groups.pop();
}
}
/// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the
/// order they opened.
fn chain(&self, clips: &[Arc<Clip>]) -> Vec<Arc<Clip>> {
if self.groups.is_empty() {
return clips.to_vec();
}
let mut chain = Vec::with_capacity(clips.len() + self.groups.len());
let (mut a, mut b) = (clips.iter().peekable(), self.groups.iter().peekable());
while let (Some(x), Some(y)) = (a.peek(), b.peek()) {
let next = if x.id < y.id { a.next() } else { b.next() };
chain.extend(next.cloned());
}
chain.extend(a.chain(b).cloned());
chain
}
/// Add `node` under `clips` (in the groups open); `None` when it was left out.
pub fn push(&mut self, mut node: Node, clips: &[Arc<Clip>]) -> Option<NodeId> {
self.merge = None;
self.elements = self.elements.saturating_add(node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()));
let chain = self.chain(clips);
let size = node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum()) + chain.len();
self.elements = self.elements.saturating_add(size);
if self.drawn.len() >= MAX_NODES || self.elements > MAX_DRAWN {
self.warn(TOO_MUCH);
return None;
@@ -204,7 +249,7 @@ impl Out {
}
node.id = self.doc.alloc_id();
let id = node.id;
self.drawn.push((clips.to_vec(), node));
self.drawn.push((chain, node));
Some(id)
}
@@ -234,14 +279,15 @@ impl Out {
/// clips (`gsave fill grestore stroke`).
fn stroke(&mut self, bp: BezPath, st: StrokeLayer, clips: &[Arc<Clip>]) {
self.count(&st.paint);
if self.merge.as_ref() == Some(&bp)
&& let Some((chain, last)) = self.drawn.last_mut()
&& chain.len() == clips.len()
&& chain.iter().zip(clips).all(|(a, b)| Arc::ptr_eq(a, b))
{
last.appearance.items.push(AppearanceItem::Stroke(st));
self.merge = None;
return;
if self.merge.as_ref() == Some(&bp) {
let here = self.chain(clips);
if let Some((chain, last)) = self.drawn.last_mut()
&& chain.iter().map(|c| c.id).eq(here.iter().map(|c| c.id))
{
last.appearance.items.push(AppearanceItem::Stroke(st));
self.merge = None;
return;
}
}
let n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() });
self.push(n, clips);
@@ -256,7 +302,7 @@ impl Out {
return None;
}
self.next_clip += 1;
Some(Arc::new(Clip { id: self.next_clip, path, rule }))
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }))
}
}
@@ -472,14 +518,17 @@ impl Interp<'_> {
}
fn clip_with(&mut self, path: BezPath, rule: FillRule) {
if let Some(c) = self.out.clip(path, rule) {
let Some(c) = self.out.clip(path, rule) else { return };
if self.g.clips.len() < MAX_NEST {
self.g.clips.push(c);
} else {
self.out.warn(&deep_clips());
}
}
/// The area the clip leaves, in document space (the page without a clip).
fn clip_bounds(&self) -> Rect {
self.g.clips.last().map_or(self.out.frame, |c| c.path.bounding_box())
self.g.clips.iter().rev().find_map(|c| c.region.as_ref()).map_or(self.out.frame, |(p, _)| p.bounding_box())
}
// ---------- colour ----------
+20
View File
@@ -52,6 +52,9 @@ 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.
pub illustrator: bool,
}
fn new_dict() -> DictRef {
@@ -128,6 +131,7 @@ impl<'a> Interp<'a> {
g,
saved: vec![],
out,
illustrator: false,
}
}
@@ -135,10 +139,26 @@ impl<'a> Interp<'a> {
pub fn run(&mut self) -> Res {
loop {
let Some(o) = self.lex.next()? else { return Ok(()) };
// An Illustrator 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 {
"u" => Some(true),
"U" => Some(false),
_ => None,
},
_ => None,
};
if group == Some(true) {
self.out.begin_group();
}
match self.exec_token(o) {
Err(PsError::Quit) => return Ok(()),
r => r?,
}
if group == Some(false) {
self.out.end_group();
}
}
}
+15 -2
View File
@@ -9,6 +9,10 @@
//! - `data`: data the program reads from itself through decoding filters, and images.
//! - `text`: fonts by name and type as point type.
//!
//! 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.
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its TIFF
@@ -26,6 +30,8 @@ mod text;
mod tests;
#[cfg(test)]
mod tests_fontnames;
#[cfg(test)]
mod tests_illustrator;
use std::sync::Arc;
@@ -60,6 +66,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.
illustrator: bool,
}
/// The four numbers of a bounding box comment's value, when it has them and they make a box.
@@ -90,6 +98,10 @@ impl Dsc {
}
}
let value = |key: &str| line.strip_prefix(key).map(str::trim);
let creator = value("%%Creator:").is_some_and(|v| v.contains("Illustrator")); // brand-ok: the creator its files name
if header && (line.starts_with("%AI") || line.starts_with("%%AI") || creator) {
d.illustrator = true;
}
if let Some(v) = value("%%HiResBoundingBox:") {
if header || atend {
hires = box_of(v).or(hires);
@@ -136,6 +148,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
doc.layers.clear();
let page = Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]);
let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, frame));
it.illustrator = dsc.illustrator;
let result = it.run();
it.release();
let mut out = it.out;
@@ -165,8 +178,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
}
}
/// The document of what `out` drew: one layer, clipped art in clipping groups, the spot inks as
/// spot swatches, in CMYK when most process colours were.
/// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in
/// groups), the spot inks as spot swatches, in CMYK when most process colours were.
fn finish(mut out: Out) -> Imported {
let drawn = std::mem::take(&mut out.drawn);
let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn);
+14
View File
@@ -195,6 +195,20 @@ fn clips_become_clipping_groups() {
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }));
}
#[test]
fn clips_nest_only_so_deep() {
// Each clip inside the last one used to nest a clipping group one level deeper, past the stack.
let r = read("1 1 200000 { pop 0 0 50 50 rectclip } for 1 1 5 5 rectfill");
let mut depth = 0;
let mut n = objects(&r.document)[0].clone();
while let NodeKind::Group { children, clip: true } = &n.kind {
depth += 1;
n = children.last().unwrap().clone();
}
assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST);
assert!(r.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", r.warnings);
}
#[test]
fn shadings_become_gradients() {
let axial =
@@ -0,0 +1,91 @@
//! Illustrator files (Illustrator 3–8 and their EPS): the groups they write (`u` … `U`) come in
//! as groups, nested as they were, between and inside clipping groups.
use std::sync::Arc;
use vectorcraft_doc::clipnest::MAX_NEST;
use vectorcraft_doc::{Node, NodeKind};
use crate::import::import;
/// A minimal file in Illustrator's format: its header comments, a prolog of our own defining the
/// operators `body` uses (`u` and `U` as `ugroup` and `Ugroup`), and `body`.
fn ai(header: &str, ugroup: &str, body: &str) -> Vec<u8> {
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n{header}%%BoundingBox: 0 0 100 100\n%%EndComments\n%%BeginProlog\n\
/m {{moveto}} def /L {{lineto}} def /f {{closepath fill}} def /S {{stroke}} def /g {{setgray}} def\n\
/u {{{ugroup}}} def /U {{{}}} def\n%%EndProlog\n{body}\nshowpage\n%%EOF\n",
if ugroup.contains("gsave") { "grestore" } else { "" }
)
.into_bytes()
}
const AI8: &str = "%%Creator: Adobe Illustrator(R) 8.0\n%AI5_FileFormat 4.0\n";
/// A triangle filled at `x`.
fn tri(x: u32) -> String {
format!("{x} {x} m {} {x} L {} {} L f", x + 5, x + 5, x + 5)
}
/// The layer's objects as a tree: `p` a path, `c` a clip path, `g[…]` a group, `k[…]` a clipping
/// group.
fn tree(nodes: &[Arc<Node>]) -> String {
let one = |n: &Arc<Node>| match &n.kind {
NodeKind::Group { children, clip: false } => format!("g[{}]", tree(children)),
NodeKind::Group { children, clip: true } => format!("k[{}]", tree(children)),
NodeKind::Path { clipping: true, .. } => "c".into(),
NodeKind::Path { .. } => "p".into(),
k => format!("{k:?}"),
};
nodes.iter().map(one).collect::<Vec<_>>().join(" ")
}
fn read(header: &str, ugroup: &str, body: &str) -> (String, Vec<String>) {
let r = import(&ai(header, ugroup, body)).unwrap();
(tree(r.document.layers[0].children().unwrap()), r.warnings)
}
#[test]
fn groups_and_nested_groups_come_in_as_groups() {
let body = format!("u {} u {} {} U U {}", tri(10), tri(30), tri(50), tri(70));
assert_eq!(read(AI8, "", &body), ("g[p g[p p]] p".into(), vec![]));
// Illustrator 3's header names it only as the creator; a prolog whose groups save and restore
// the graphics state groups the same.
assert_eq!(read("%%Creator:Adobe Illustrator(TM) 3.2\n", "gsave", &body).0, "g[p g[p p]] p");
// Groups of one object, and groups next to each other.
assert_eq!(read(AI8, "", &format!("u {} U u {} U", tri(10), tri(30))).0, "g[p] g[p]");
}
#[test]
fn groups_nest_with_clipping_groups() {
// A clipping group inside a group, a group inside a clipping group.
let clip = "gsave 0 0 m 50 0 L 50 50 L 0 50 L closepath clip newpath";
let body = format!("u {} {clip} u {} {} U grestore {} U", tri(10), tri(20), tri(30), tri(40));
assert_eq!(read(AI8, "", &body).0, "g[p k[c g[p p]] p]");
// A fill and a stroke of one path in a group stay one object.
let body = "u 10 10 m 20 10 L 20 20 L closepath gsave 0 g fill grestore S U";
let r = import(&ai(AI8, "", body)).unwrap();
let layer = r.document.layers[0].children().unwrap();
assert_eq!(tree(layer), "g[p]");
assert_eq!(layer[0].children().unwrap()[0].appearance.items.len(), 2);
}
#[test]
fn other_postscript_keeps_its_own_u() {
// A program that isn't Illustrator's may name its own procedures `u` and `U`.
let body = format!("u {} {} U", tri(10), tri(30));
assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p");
}
#[test]
fn unbalanced_and_runaway_groups_are_read_safely() {
// Ends without a beginning end nothing; groups left open hold the rest.
assert_eq!(read(AI8, "", &format!("U U {} u {} u {}", tri(10), tri(20), tri(30))).0, "p g[p g[p]]");
// Groups nest only so deep: the deeper ones' art goes into the innermost group read, and the
// groups around them close where they should.
let n = MAX_NEST + 50;
let body = format!("{} {} {} {}", "u ".repeat(n), tri(10), "U ".repeat(n), tri(30));
let (t, warnings) = read(AI8, "", &body);
assert_eq!(t, format!("{}p{} p", "g[".repeat(MAX_NEST), "]".repeat(MAX_NEST)));
assert!(warnings.iter().any(|w| w.contains("nested more than")), "{warnings:?}");
}
+16
View File
@@ -184,3 +184,19 @@ fn pretty_files_are_indented_like_serde_json() {
// The same JSON as the compact file.
assert_eq!(json_of(&bytes), json_of(&save(&d, false)));
}
/// #451: a guide's artboard is written only for artboard guides, so canvas guides save as they
/// did, and a file without it (an older version's) opens with canvas guides.
#[test]
fn artboard_guides_round_trip_and_canvas_guides_save_as_before() {
use vectorcraft_doc::Guide;
let mut d = Document::new(100.0, 100.0);
let id = d.artboards[0].id;
d.guides = vec![Guide::new(true, 10.0), Guide { artboard: Some(id), ..Guide::new(false, 20.0) }];
let mut v = json_of(&save(&d, false));
assert_eq!(v["document"]["guides"], json!([{"vertical": true, "pos": 10.0}, {"vertical": false, "pos": 20.0, "artboard": id}]));
assert_eq!(load(&save(&d, false)).unwrap().guides, d.guides);
v["document"]["guides"] = json!([{"vertical": false, "pos": 20.0}]);
let old = load(&serde_json::to_vec(&v).unwrap()).unwrap();
assert_eq!(old.guides, [Guide::new(false, 20.0)]);
}
+73
View File
@@ -1,5 +1,7 @@
//! The anchor-based editable path model.
use std::collections::BTreeSet;
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape};
use serde::{Deserialize, Serialize};
@@ -268,6 +270,77 @@ impl SubPath {
self.anchors.insert(seg + 1, mid);
seg + 1
}
/// Make anchor `i` smooth: handles in line with its neighbours (its one neighbour at an open
/// end), each a third of the way to that side's neighbour. A smooth anchor with both handles
/// keeps them. False when there is no such anchor or no direction (the neighbours coincide).
pub fn smooth_anchor(&mut self, i: usize) -> bool {
let n = self.anchors.len();
let prev = if i > 0 {
Some(i - 1)
} else if self.closed {
n.checked_sub(1)
} else {
None
};
let next = if i + 1 < n {
Some(i + 1)
} else if self.closed {
Some(0)
} else {
None
};
let at = |j: Option<usize>| j.and_then(|j| self.anchors.get(j)).map(|a| a.p);
let (prev, next) = (at(prev), at(next));
let Some(a) = self.anchors.get_mut(i) else { return false };
if a.kind == AnchorKind::Smooth && a.has_in() && a.has_out() {
return true;
}
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l <= 1e-9 {
return false;
}
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
true
}
/// Cut at the anchors `at`: each becomes the end of one open piece and the start of the next,
/// its handle off each piece retracted (the shape doesn't change). A closed subpath opens at
/// its first cut, so one cut leaves one piece whose ends coincide. The ends of an open subpath
/// and indices past the end don't cut; with no cut left, the subpath comes back as it is.
pub fn cut_at(&self, at: &BTreeSet<usize>) -> Vec<SubPath> {
let n = self.anchors.len();
let mut cuts = at.iter().copied().filter(|&i| i < n && (self.closed || (i > 0 && i + 1 < n))).peekable();
let Some(&first) = cuts.peek().filter(|_| n >= 2) else { return vec![self.clone()] };
// A closed subpath's anchors from the first cut round to it again; an open one's as they are.
let (run, cuts): (Vec<Anchor>, BTreeSet<usize>) = if self.closed {
(self.anchors.iter().cycle().skip(first).take(n + 1).copied().collect(), cuts.map(|c| (c + n - first) % n).chain([n]).collect())
} else {
(self.anchors.clone(), cuts.collect())
};
let mut pieces = vec![];
let mut cur: Vec<Anchor> = vec![];
for (i, a) in run.into_iter().enumerate() {
if !cuts.contains(&i) {
cur.push(a);
continue;
}
if !cur.is_empty() {
cur.push(Anchor { h_out: a.p, kind: AnchorKind::Corner, ..a });
pieces.push(SubPath::new(std::mem::take(&mut cur), false));
}
if i < n {
cur.push(Anchor { h_in: a.p, kind: AnchorKind::Corner, ..a });
}
}
if cur.len() > 1 {
pieces.push(SubPath::new(cur, false));
}
pieces
}
/// Signed area via the shoelace formula on the Bézier path (positive = clockwise in y-down).
pub fn area(&self) -> f64 {
let mut bp = BezPath::new();
+31 -2
View File
@@ -47,6 +47,18 @@ impl CornerKind {
}
}
/// The largest corner radius a `w` × `h` rectangle draws: half its shorter side.
pub fn max_corner_radius(w: f64, h: f64) -> f64 {
w.abs().min(h.abs()) / 2.0
}
/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than
/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when
/// negative or not a number.
pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 {
radius.max(0.0).min(max_corner_radius(w, h))
}
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
@@ -63,7 +75,6 @@ pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec<usize> {
/// The anchors of [`rectangle_with_corners`], each with its corner.
fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usize, Anchor)> {
let r = r.abs();
let max = r.width().min(r.height()) / 2.0;
// Each corner, with the directions of the side arriving at it and of the side leaving it.
let corners = [
(Point::new(r.x0, r.y0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0)),
@@ -74,7 +85,7 @@ fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usiz
let mut out = Vec::with_capacity(8);
let mut top_left_start = None;
for (k, ((c, din, dout), (rad, kind))) in corners.into_iter().zip(radii.into_iter().zip(kinds)).enumerate() {
let rad = rad.max(0.0).min(max);
let rad = fitted_corner_radius(r.width(), r.height(), rad);
if rad <= 1e-9 {
out.push((k, Anchor::corner(c)));
continue;
@@ -312,6 +323,24 @@ mod tests {
assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]);
}
/// #442: on a rectangle that isn't square, every corner is a circular arc of the same radius,
/// and radii past the limit stop at half the shorter side, whichever side a corner is on.
#[test]
fn corners_of_a_non_square_rectangle_are_alike() {
let r = Rect::new(0.0, 0.0, 200.0, 80.0);
assert_eq!(max_corner_radius(-200.0, 80.0), 40.0);
assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]);
for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] {
for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] {
let a: Vec<Point> = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect();
for (i, j) in [(7, 0), (1, 2), (3, 4), (5, 6)] {
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
assert!((x - drawn).abs() < 1e-9 && (y - drawn).abs() < 1e-9, "radius {radius}: a corner spans {x} × {y}");
}
}
}
}
#[test]
fn polygon_first_vertex_up() {
let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0);
+29
View File
@@ -179,6 +179,35 @@ proptest! {
prop_assert!((q.length() - p.length()).abs() < 1e-3 * p.length().max(1.0));
}
/// Cutting at anchors keeps every segment as it was, in order (a closed subpath's from its
/// first cut), in open pieces of two or more anchors. A smoothed corner gets handles on a line
/// through it.
#[test]
fn cut_at_keeps_the_segments(p in arb_path_data(), cuts in proptest::collection::btree_set(0usize..12, 0..4), pick in 0usize..100) {
let sp = &p.subpaths[pick % p.subpaths.len()];
let n = sp.anchors.len();
let pieces = sp.cut_at(&cuts);
let segs = |s: &vectorcraft_geom::SubPath| (0..s.segment_count()).map(|i| s.segment(i)).collect::<Vec<_>>();
let mut want = segs(sp);
if pieces.len() > 1 || pieces.first().is_some_and(|q| q.closed != sp.closed) {
prop_assert!(pieces.iter().all(|q| !q.closed && q.anchors.len() >= 2));
if sp.closed {
want.rotate_left(cuts.iter().copied().find(|&c| c < n).unwrap_or(0));
}
} else {
prop_assert_eq!(&pieces, &vec![sp.clone()]);
}
prop_assert_eq!(pieces.iter().flat_map(segs).collect::<Vec<_>>(), want);
let mut s = sp.clone();
let i = pick % n.max(1);
let corner = s.anchors.get(i).is_some_and(|a| a.kind == vectorcraft_geom::AnchorKind::Corner);
if s.smooth_anchor(i) && corner {
let a = s.anchors[i];
let (u, v) = (a.h_in - a.p, a.h_out - a.p);
prop_assert!(u.cross(v).abs() <= 1e-6 * (1.0 + u.hypot() * v.hypot()) && u.dot(v) <= 0.0, "{a:?}");
}
}
/// Polygon area via the shoelace formula equals the path's signed area magnitude and
/// SubPath::area.
#[test]
+5 -2
View File
@@ -13,7 +13,7 @@ mod wmf;
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::clipnest::{Clip, Drawn, nest};
use vectorcraft_doc::clipnest::{Clip, Drawn, MAX_NEST, deep_clips, nest};
use vectorcraft_doc::{
Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind,
StrokeLayer, TextObject,
@@ -696,8 +696,11 @@ impl Player {
if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) {
return;
}
if self.dc.clips.len() >= MAX_NEST {
return self.warn(&deep_clips());
}
self.next_clip += 1;
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, path, rule }));
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }));
}
/// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read).
+16
View File
@@ -309,3 +309,19 @@ fn damaged_files_fail_or_import_partly() {
let huge = hand_wmf(100, 100, &[(0x0324, le16(&[i16::MAX])), (0x0538, le16(&[-1]))]);
assert!(import(&huge).is_ok());
}
#[test]
fn clips_nest_only_so_deep() {
// Each clip inside the last one used to nest a clipping group one level deeper, past the stack.
let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000];
records.push((43, le(&[100, 100, 200, 200])));
let back = import(&hand_emf(5000, 3000, &records)).unwrap();
let mut depth = 0;
let mut n = back.document.layers[0].children().unwrap()[0].clone();
while let NodeKind::Group { children, clip: true } = &n.kind {
depth += 1;
n = children.last().unwrap().clone();
}
assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST);
assert!(back.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", back.warnings);
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub use edit::{
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{SHAPE_BUILDER_MAX_SEGMENTS, interior_point, live_paint, shape_builder};
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+253 -21
View File
@@ -21,7 +21,7 @@ use std::collections::HashMap;
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _};
use linesweeper::topology::{Topology, WindingNumber};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_geom::{FillRule, PathData, SubPath};
use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg};
use crate::pathfinder::{Region, Shape};
@@ -102,7 +102,12 @@ struct Outlines {
impl Outlines {
fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self {
let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect();
// A zero-length segment (a point) is the source of no edge, even one running through it.
let segs = inputs
.iter()
.flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i)))
.filter(|(r, ..)| r.width() > 0.0 || r.height() > 0.0)
.collect();
// The sweep moves points by a few `eps` at most.
Self { segs, tol: 16.0 * eps }
}
@@ -292,26 +297,172 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
/// than the filled areas' arrangement, so past this only the filled areas count.
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
/// Shape Builder regions of `shapes` (back → front), and the pieces of their open paths.
/// The Shape Builder's arrangement: see [`shape_builder`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BuilderArrangement {
pub regions: Vec<Region>,
/// The pieces of the paths with no closed subpath, keyed by input index.
pub lines: Vec<Shape>,
/// The pieces of the other paths' outlines, keyed by input index (the front-most where inputs
/// share one); only when asked for.
pub edges: Vec<Shape>,
}
/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with
/// `edges`, the pieces of their outlines (what erasing an edge deletes).
///
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
/// its index in `shapes`. With no open path, more than [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or
/// a map that can't be built, only the filled areas count and there are no pieces.
pub fn shape_builder(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
/// its index in `shapes`. Outlines are cut the same way. With no open path, more than
/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas
/// count and there are no pieces.
pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
if has_open && segments <= SHAPE_BUILDER_MAX_SEGMENTS {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
let (faces, edges) = live_paint(&keyed);
if !faces.is_empty() || !edges.is_empty() {
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
return (faces, edges.into_iter().filter(|e| is_line(e.key)).collect());
let planar = ((has_open || edges) && segments <= SHAPE_BUILDER_MAX_SEGMENTS)
.then(|| {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
live_paint(&keyed)
})
.filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty());
let Some((faces, pieces)) = planar else {
return BuilderArrangement { regions: crate::regions(shapes), ..Default::default() };
};
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
BuilderArrangement {
regions: if has_open { faces } else { crate::regions(shapes) },
lines: if has_open { lines } else { Vec::new() },
edges: if edges { outline } else { Vec::new() },
}
}
/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the
/// open subpaths of filled paths that do.
pub fn encloses_area(sp: &SubPath) -> bool {
let mut closed = sp.clone();
closed.closed = true;
let mut bp = BezPath::new();
closed.to_bezpath_into(&mut bp);
let size = bp.bounding_box().size();
bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height)
}
/// `path` without `piece`, a run along one of its subpaths between two points (a Shape Builder
/// edge): a closed subpath opens there, an open one splits in two (or loses an end), and a piece
/// that runs all the way round takes its subpath. With `fill_closes`, open subpaths that enclose an
/// area ([`encloses_area`]) run on round an invisible closing edge, as the Shape Builder sees a
/// filled path: a piece may run over it, and it never comes back as a visible segment. None when
/// the piece doesn't lie on `path` within `tol`.
pub fn cut_out(path: &PathData, piece: &SubPath, fill_closes: bool, tol: f64) -> Option<PathData> {
let n = piece.segment_count();
if n == 0 {
return None;
}
// A point well inside the piece: the middle of its longest segment.
let arclen = |k: usize| kurbo::ParamCurveArclen::arclen(&piece.segment(k), 1e-3);
let mid = (0..n).map(|k| (arclen(k), k)).max_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, k)| piece.segment(k).eval(0.5))?;
let (a, b) = (piece.anchors.first()?.p, piece.anchors.last()?.p);
let mut bp = BezPath::new();
piece.to_bezpath_into(&mut bp);
if bp.perimeter(1e-6) <= tol {
// A speck (where paths touch): there's nothing to cut.
return None;
}
// A piece that ends where it starts runs all the way round.
let whole = piece.closed || a.distance(b) <= tol;
// Each subpath as the piece may run along it: round its closing edge when it has one.
let ring = |sp: &SubPath| SubPath { anchors: sp.anchors.clone(), closed: sp.closed || (fill_closes && encloses_area(sp)) };
let (si, geom, um) = path
.subpaths
.iter()
.enumerate()
.filter_map(|(i, sp)| {
let g = ring(sp);
let (u, d) = nearest_u(&g, mid)?;
(d <= tol).then_some((i, g, u, d))
})
.min_by(|x, y| x.3.total_cmp(&y.3))
.map(|(i, g, u, _)| (i, g, u))?;
let len = geom.segment_count() as f64;
let real = path.subpaths.get(si)?.segment_count() as f64;
let at = |p: Point| nearest_u(&geom, p).filter(|(_, d)| *d <= tol).map(|(u, _)| u);
// The parameter spans of `geom` that stay (unrolled past the end of a ring).
let keep: Vec<(f64, f64)> = if whole {
Vec::new()
} else if geom.closed {
let (ua, ub) = (at(a)?, at(b)?);
let fwd = |from: f64, to: f64| (to - from).rem_euclid(len);
// The piece runs from one end through `mid` to the other; what stays runs on round.
let (start, end) = if fwd(ua, um) <= fwd(ua, ub) { (ub, ua) } else { (ua, ub) };
let rest = fwd(start, end);
if rest <= 1e-9 || len - rest <= 1e-9 {
Vec::new()
} else {
// Where the ring closes with an invisible edge (`real..len`), what stays stops at it.
let parts: &[(f64, f64)] = if real >= len { &[(0.0, 2.0 * len)] } else { &[(0.0, real), (len, len + real)] };
parts.iter().map(|&(lo, hi)| (start.max(lo), (start + rest).min(hi))).filter(|(x, y)| y - x > 1e-9).collect()
}
} else {
let (ua, ub) = (at(a)?, at(b)?);
let (lo, hi) = (ua.min(ub), ua.max(ub));
if !(lo - 1e-9..=hi + 1e-9).contains(&um) {
return None;
}
[(0.0, lo), (hi, len)].into_iter().filter(|(x, y)| y - x > 1e-9).collect()
};
let mut subpaths: Vec<SubPath> = path.subpaths.iter().take(si).cloned().collect();
subpaths.extend(keep.into_iter().filter_map(|(u0, u1)| span(&geom, u0, u1)).map(|sp| snap_ends(sp, [a, b], tol)));
subpaths.extend(path.subpaths.iter().skip(si + 1).cloned());
Some(PathData::new(subpaths))
}
/// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance.
fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> {
use kurbo::ParamCurveNearest;
(0..sp.segment_count())
.map(|k| {
let n = sp.segment(k).nearest(p, 1e-9);
(k as f64 + n.t, n.distance_sq.sqrt())
})
.min_by(|x, y| x.1.total_cmp(&y.1))
}
/// `sp` with its end anchors within `tol` of one of `to` put exactly on it (where the planar map
/// put the junction a piece ends at), handles moving with them.
fn snap_ends(mut sp: SubPath, to: [Point; 2], tol: f64) -> SubPath {
let last = sp.anchors.len().saturating_sub(1);
for i in [0, last] {
if let Some(an) = sp.anchors.get_mut(i)
&& let Some(q) = to.iter().find(|q| q.distance(an.p) <= tol)
{
let d = *q - an.p;
an.p = *q;
an.h_in += d;
an.h_out += d;
}
}
(crate::regions(shapes), Vec::new())
sp
}
/// The run of `sp` from parameter `u0` to `u1` (`u0 < u1`, unrolled past the end of a closed one),
/// open.
fn span(sp: &SubPath, u0: f64, u1: f64) -> Option<SubPath> {
let n = sp.segment_count();
if n == 0 || !(u0.is_finite() && u1.is_finite()) {
return None;
}
let first = u0.floor().max(0.0) as usize;
let last = (u1.ceil().max(0.0) as usize).min(first + 2 * n);
let segs: Vec<Seg> = (first..last)
.filter_map(|k| {
let (t0, t1) = ((u0 - k as f64).clamp(0.0, 1.0), (u1 - k as f64).clamp(0.0, 1.0));
(t1 - t0 > 1e-12).then(|| Seg { c: sp.segment(k % n).subsegment(t0..t1), line: sp.segment_is_line(k % n) })
})
.collect();
segs_to_subpath(&segs, false)
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
@@ -618,7 +769,7 @@ pub fn interior_point(path: &PathData) -> Option<Point> {
mod tests {
use super::*;
use proptest::prelude::*;
use vectorcraft_geom::{Rect, SubPath, shapes};
use vectorcraft_geom::{Rect, shapes};
fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape {
Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key)
@@ -766,14 +917,14 @@ mod tests {
fn shape_builder_lines_cut_regions() {
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
let (regions, lines) = shape_builder(&s);
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
assert_eq!(regions.len(), 2);
assert!(regions.iter().all(|r| r.sources == [0]));
assert_eq!(lines.len(), 3);
assert!(lines.iter().all(|l| l.key == 1 && !l.path.is_closed()));
// Lines alone enclose a region nothing fills.
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 0), line((-10.0, 80.0), (110.0, 80.0), 0)];
let (regions, lines) = shape_builder(&s);
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
assert_eq!(regions.len(), 1);
assert!(regions[0].sources.is_empty() && regions[0].top().is_none());
assert_eq!(lines.len(), 9);
@@ -782,10 +933,10 @@ mod tests {
#[test]
fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() {
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() });
// A broken line can't be swept: what the filled areas give (nothing here, without a panic).
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)];
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() });
// Zero-length and doubled-back lines don't break it either.
let s = [
rect(0.0, 0.0, 100.0, 100.0, 0),
@@ -793,7 +944,7 @@ mod tests {
line((0.0, 0.0), (100.0, 0.0), 2),
line((50.0, 0.0), (50.0, 100.0), 3),
];
let (regions, _) = shape_builder(&s);
let regions = shape_builder(&s, false).regions;
assert_eq!(regions.len(), 2);
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
}
@@ -815,14 +966,75 @@ mod tests {
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
}
let started = std::time::Instant::now();
let (regions, lines) = shape_builder(&s);
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)");
assert!(lines.len() >= 12, "{}", lines.len());
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
// Past the cap, only the filled areas count.
s.push(line((0.0, 0.0), (1.0, 1.0), 2));
s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k)))));
assert!(shape_builder(&s).1.is_empty());
assert!(shape_builder(&s, false).lines.is_empty());
}
fn poly(pts: &[(f64, f64)], closed: bool) -> SubPath {
SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), closed)
}
fn ends(sp: &SubPath) -> (Point, Point) {
(sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p)
}
#[test]
fn shape_builder_edges_are_the_outlines_cut_where_paths_meet() {
// Two overlapping squares: each outline in two pieces, cut where the other crosses it.
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 50.0, 150.0, 150.0, 1)];
let with = shape_builder(&s, true);
assert_eq!(with.regions, shape_builder(&s, false).regions, "the regions don't change");
assert!(with.lines.is_empty() && shape_builder(&s, false).edges.is_empty());
assert_eq!(with.edges.len(), 4);
for key in [0, 1] {
assert_eq!(with.edges.iter().filter(|e| e.key == key && !e.path.is_closed()).count(), 2);
}
let inner = with.edges.iter().find(|e| e.key == 0 && e.path.bounds() == Some(Rect::new(50.0, 50.0, 100.0, 100.0))).expect("A inside B");
// Erasing it opens A there.
let cut = cut_out(&s[0].path, &inner.path.subpaths[0], false, 1e-3).expect("on A");
assert_eq!(cut.subpaths.len(), 1);
let sp = &cut.subpaths[0];
assert!(!sp.closed && sp.anchors.len() == 5, "{sp:?}");
let (a, b) = ends(sp);
assert!([a, b].contains(&Point::new(100.0, 50.0)) && [a, b].contains(&Point::new(50.0, 100.0)), "{a:?} {b:?}");
assert!((cut.length() - 300.0).abs() < 1e-6, "{}", cut.length());
// Not on B.
assert_eq!(cut_out(&s[1].path, &inner.path.subpaths[0], false, 1e-3), None);
// A lone square's edge is its whole outline: erasing it leaves nothing.
let lone = shape_builder(&s[..1], true);
assert_eq!(lone.edges.len(), 1);
assert!(cut_out(&s[0].path, &lone.edges[0].path.subpaths[0], false, 1e-3).unwrap().is_empty());
}
#[test]
fn cut_out_splits_open_paths_and_keeps_closing_edges_invisible() {
let open = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0)], false));
let cut = cut_out(&open, &poly(&[(60.0, 0.0), (30.0, 0.0)], false), false, 1e-3).unwrap();
let mut parts: Vec<(Point, Point)> = cut.subpaths.iter().map(ends).collect();
parts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
assert_eq!(parts, vec![(Point::new(0.0, 0.0), Point::new(30.0, 0.0)), (Point::new(60.0, 0.0), Point::new(100.0, 0.0))]);
// An end piece only shortens it.
let cut = cut_out(&open, &poly(&[(70.0, 0.0), (100.0, 0.0)], false), false, 1e-3).unwrap();
assert_eq!(cut.subpaths.iter().map(ends).collect::<Vec<_>>(), vec![(Point::new(0.0, 0.0), Point::new(70.0, 0.0))]);
// A filled open path runs on round its invisible closing edge (100,100) → (0,0).
let corner = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false));
let piece = poly(&[(100.0, 50.0), (100.0, 100.0), (50.0, 50.0)], false);
assert_eq!(cut_out(&corner, &piece, false, 1e-3), None, "without the fill it isn't on the path");
let cut = cut_out(&corner, &piece, true, 1e-3).unwrap();
assert_eq!(cut.subpaths.len(), 1);
assert_eq!(cut.subpaths[0].anchors.iter().map(|a| a.p).collect::<Vec<_>>(), [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)].map(Point::from));
// Cut elsewhere, the closing edge stays invisible: two open runs, not one through it.
let cut = cut_out(&corner, &poly(&[(20.0, 0.0), (60.0, 0.0)], false), true, 1e-3).unwrap();
assert_eq!(cut.subpaths.len(), 2);
assert!(cut.subpaths.iter().all(|sp| !sp.closed));
assert!((cut.length() - 160.0).abs() < 1e-6, "{}", cut.length());
}
fn arb_segment() -> impl Strategy<Value = Shape> {
@@ -872,6 +1084,26 @@ mod tests {
prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions);
}
/// Every edge and line piece cuts out of the input it is keyed by, which loses its length
/// and nothing else.
#[test]
fn pieces_cut_out_of_their_inputs(shapes in prop::collection::vec(arb_segment(), 1..6)) {
let arr = shape_builder(&shapes, true);
for e in arr.edges.iter().chain(&arr.lines) {
let (Some(s), Some(piece)) = (shapes.get(e.key as usize), e.path.subpaths.first()) else { continue };
let len = e.path.length();
let cut = cut_out(&s.path, piece, false, 0.05);
if len <= 0.05 {
// A speck where two paths touch.
prop_assert!(cut.is_none());
continue;
}
prop_assert!(cut.is_some(), "{:?} not on {:?}", piece, s.path);
let lost = s.path.length() - cut.map_or(0.0, |c| c.length());
prop_assert!((lost - len).abs() <= 0.05 + 1e-3 * len, "lost {} for a piece {} long", lost, len);
}
}
/// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics.
#[test]
fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) {
+6
View File
@@ -103,6 +103,11 @@ fn group_layer(
Some(l)
}
/// The note on a file carrying an editor's private data (#472): its PDF part, the one read, holds
/// only the art on its artboards (art on the pasteboard is in the private data alone).
pub(crate) const OFF_ARTBOARD_NOTE: &str =
"only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open";
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
let original = crate::pages::open(bytes, opts.password.as_deref())?;
@@ -177,6 +182,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let ai = crate::pages::has_private_data(page);
if ai {
drop_page_fill(&mut parts, xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop)));
b.warn(OFF_ARTBOARD_NOTE);
}
// A file with several artboards writes, on each page, the art of its neighbours that
// reaches into the page's box: art lying wholly outside this page is theirs (each page
+14
View File
@@ -434,6 +434,20 @@ fn import_handmade_pdf() {
assert!(close(l[2].geometric_bounds().unwrap(), Rect::new(150.0, 270.0, 190.0, 300.0), 1e-6));
}
/// Art off the page opens on the pasteboard round its artboard, not cut away (#472).
#[test]
fn import_keeps_art_outside_the_page() {
let content = "0 0 1 rg 10 10 50 30 re f 0 1 0 rg -300 -300 50 50 re f 1 0 0 rg 500 500 20 20 re f";
let d = import(&handmade_pdf(content, "[0 0 200 300]")).unwrap();
assert!(close(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 300.0), 1e-9));
let bounds: Vec<Rect> = leaves(&d).iter().map(|n| n.geometric_bounds().unwrap()).collect();
let want = [Rect::new(10.0, 260.0, 60.0, 290.0), Rect::new(-300.0, 550.0, -250.0, 600.0), Rect::new(500.0, -220.0, 520.0, -200.0)];
assert_eq!(bounds.len(), want.len(), "{bounds:?}");
for (b, w) in bounds.iter().zip(want) {
assert!(close(*b, w, 1e-6), "{b:?} vs {w:?}");
}
}
#[test]
fn import_invalid_dash_array_strokes_solid() {
// A dash array with a negative value is invalid (PDF 32000-1 §8.4.3.6): the line is solid. A valid
+14
View File
@@ -151,6 +151,20 @@ fn page_fill(before: &str, after: &str, ai: bool) -> Document {
import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None)).unwrap()
}
/// A file with an editor's private data says that art off its artboards isn't in the part read
/// (#472); a plain PDF has nothing to say.
#[test]
fn an_ai_file_notes_that_its_art_off_the_artboards_is_not_read() {
for ai in [false, true] {
let page = PdfPage {
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
};
let r = import_with_report(&pdf_with_catalog(&[page], &[], "", None), &ImportOptions::default()).unwrap();
assert_eq!(r.warnings, if ai { vec![crate::import::OFF_ARTBOARD_NOTE.to_string()] } else { vec![] });
}
}
fn path_count(d: &Document) -> usize {
let mut n = 0;
d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. })));
+9 -7
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind};
use vectorcraft_effects::RasterFx;
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
use crate::export::sanitize_id;
@@ -600,19 +600,21 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
let [_, b, c, _, _, _] = xf.as_coeffs();
b.abs() < 1e-9 && c.abs() < 1e-9
};
match live {
// Sizes and radii in document units.
match live.map(LiveShape::folded).as_ref() {
// CSS rounds corners only: an inverted round or chamfered corner has no border radius.
Some(LiveShape::Rectangle { radii, kinds, xf, .. })
Some(LiveShape::Rectangle { w, h, radii, kinds, xf })
if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) =>
{
let [a, _, _, d, _, _] = xf.as_coeffs();
let s = (a * d).abs().sqrt();
// The radii as drawn, none past half the shorter side (CSS draws one further when its
// neighbours leave room).
let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r));
if radii.iter().all(|r| *r <= 0.0) {
Some(Corners::Square)
} else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) {
Some(Corners::Round(vec![radii[0] * s]))
Some(Corners::Round(vec![radii[0]]))
} else {
Some(Corners::Round(radii.iter().map(|r| r.max(0.0) * s).collect()))
Some(Corners::Round(radii.to_vec()))
}
}
Some(LiveShape::Ellipse { pie, xf, .. }) if upright(xf) => {
+7
View File
@@ -84,6 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() {
kinds[1] = CornerKind::InvertedRound;
}
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
// A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS
// would draw it larger, its neighbours leaving room.
if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) {
*radii = [60.0, 8.0, 0.0, 4.0];
*kinds = Default::default();
}
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px"));
}
#[test]
+5
View File
@@ -31,3 +31,8 @@ test-fonts = ["dep:write-fonts"]
[dev-dependencies]
write-fonts = "0.48"
# Fonts registered with Windows outside its font folders ("install as shortcut", installers);
# MIT OR Apache-2.0, a safe wrapper of the registry API from the windows-rs project.
[target.'cfg(windows)'.dependencies]
windows-registry = "0.6"
+149 -32
View File
@@ -238,9 +238,9 @@ pub struct FontDb {
catalog: RwLock<Catalog>,
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
postscript: RwLock<PostScriptNames>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
/// Where the system font scan looks.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
font_dirs: Vec<PathBuf>,
font_paths: FontPaths,
/// The installed fonts' other names (localized, legacy, PostScript) by normalized name.
aliases: RwLock<HashMap<String, Vec<Alias>>>,
/// Set once the font folders have been scanned. Lookups by family name wait for the first
@@ -254,6 +254,41 @@ pub struct FontDb {
/// System fallback state: characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
/// What the last scan read, to tell when fonts were installed or removed since.
#[cfg(not(target_arch = "wasm32"))]
stamp: Mutex<Option<ScanStamp>>,
}
/// Where a database's system font scan looks: folders (read with their subfolders) and font files.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
enum FontPaths {
Fixed(Vec<PathBuf>),
/// The platform's ([`system_font_dirs`]), looked up again by each scan: fonts registered and
/// font folders added since count too.
System,
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
impl FontPaths {
fn get(&self) -> Vec<PathBuf> {
match self {
FontPaths::Fixed(paths) => paths.clone(),
FontPaths::System => system_font_dirs(),
}
}
}
/// What a system font scan read: the paths it started from, and when each folder it listed (and
/// font file named by itself) last changed. Installing or removing a font changes its folder's.
#[cfg(not(target_arch = "wasm32"))]
struct ScanStamp {
paths: Vec<PathBuf>,
modified: Vec<(PathBuf, Option<std::time::SystemTime>)>,
}
#[cfg(not(target_arch = "wasm32"))]
fn modified(path: &Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
#[cfg(not(target_arch = "wasm32"))]
@@ -747,7 +782,8 @@ pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
Some(font)
}
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
/// The platform's font folders (the system's and the user's) and, on Windows, the font files
/// registered with it outside them, scanned by [`FontDb::global`].
pub fn system_font_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if cfg!(target_arch = "wasm32") {
@@ -755,7 +791,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
}
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
dirs.extend(["/System/Library/Fonts", "/Library/Fonts", "/Network/Library/Fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
@@ -769,19 +805,65 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
} else if cfg!(windows) {
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
dirs.push(root.join("Fonts"));
// Fonts installed for the current user only (what Install does without administrator rights).
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
#[cfg(windows)]
dirs.extend(registered_font_files(&dirs));
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
// The XDG data folders' `fonts` (fontconfig's user folder; Flatpak, Snap, NixOS and Guix
// list their own data folders), relative paths ignored as the XDG spec says.
let xdg = |var| std::env::var_os(var).map(|v| std::env::split_paths(&v).filter(|p| p.is_absolute()).collect::<Vec<_>>()).unwrap_or_default();
let data_home = xdg("XDG_DATA_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".local/share")));
dirs.extend(data_home.into_iter().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
dirs.push(h.join(".local/share/fonts"));
}
// In a Flatpak sandbox, the host's fonts (system, local and the user's).
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
}
dirs
}
/// The registry key listing the fonts installed on Windows (for all users under the local
/// machine's key, for the current user under theirs): one value per face, holding its file's
/// name in the Windows font folder or its full path.
#[cfg(windows)]
const REGISTERED_FONTS: &str = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts";
/// The font files registered with Windows outside `font_dirs` (fonts installed as shortcuts to
/// where they are, or by programs into their own folders), which apps find by the registry.
#[cfg(windows)]
fn registered_font_files(font_dirs: &[PathBuf]) -> Vec<PathBuf> {
use windows_registry::{CURRENT_USER, LOCAL_MACHINE};
let values = [LOCAL_MACHINE, CURRENT_USER].into_iter().filter_map(|root| root.open(REGISTERED_FONTS).ok()).flat_map(|key| {
// A value that isn't a string isn't a font file.
key.values().map(|vs| vs.filter_map(|(_, v)| String::try_from(v).ok()).collect::<Vec<_>>()).unwrap_or_default()
});
fonts_outside(values, font_dirs)
}
/// The full paths among `registered` (font registrations' data) that aren't in `font_dirs`
/// (ignoring case, as Windows paths do), sorted and deduplicated. File names alone are files in
/// the Windows font folder, which is scanned anyway. The count is capped.
#[cfg(any(windows, test))]
pub(crate) fn fonts_outside(registered: impl IntoIterator<Item = String>, font_dirs: &[PathBuf]) -> Vec<PathBuf> {
const MAX_REGISTERED: usize = 1 << 16;
let lower = |p: &Path| PathBuf::from(p.to_string_lossy().to_lowercase());
let dirs: Vec<PathBuf> = font_dirs.iter().map(|d| lower(d)).collect();
let mut files: Vec<PathBuf> = registered
.into_iter()
.take(MAX_REGISTERED)
.map(PathBuf::from)
.filter(|p| p.is_absolute() && !dirs.iter().any(|d| lower(p).starts_with(d)))
.collect();
files.sort();
files.dedup();
files
}
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
@@ -860,8 +942,13 @@ fn style_italic(style: &str) -> bool {
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
impl FontDb {
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
/// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders,
/// read with their subfolders, and font files).
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
Self::new(FontPaths::Fixed(font_dirs))
}
fn new(font_paths: FontPaths) -> Self {
let mut faces = Vec::new();
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
// first: fallbacks for Japanese text after the requested and bundled fonts.
@@ -878,7 +965,7 @@ impl FontDb {
outlines: Mutex::new(HashMap::new()),
catalog: RwLock::new(Catalog::new()),
postscript: RwLock::new(PostScriptNames::new()),
font_dirs,
font_paths,
aliases: RwLock::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
@@ -887,6 +974,8 @@ impl FontDb {
generation: AtomicU64::new(0),
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
#[cfg(not(target_arch = "wasm32"))]
stamp: Mutex::new(None),
}
}
@@ -906,7 +995,7 @@ impl FontDb {
/// [`FontDb::scan_in_background`]).
pub fn global() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
DB.get_or_init(|| FontDb::new(FontPaths::System))
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
@@ -1042,50 +1131,78 @@ impl FontDb {
first.unwrap_or_else(|| self.scan_font_dirs())
}
/// Whether fonts were installed or removed since the last scan (a font folder or font file it
/// read changed, or a new one is to be read), for [`FontDb::load_system_fonts`] to catalog
/// them. Costs a look at each folder the scan read, not a scan. `false` before the first scan
/// and on wasm.
pub fn installed_fonts_changed(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
let paths = self.font_paths.get();
let stamp = self.stamp.lock().unwrap_or_else(|e| e.into_inner());
stamp.as_ref().is_some_and(|s| s.paths != paths || s.modified.iter().any(|(p, t)| modified(p) != *t))
}
#[cfg(target_arch = "wasm32")]
false
}
#[cfg(not(target_arch = "wasm32"))]
fn scan_font_dirs(&self) -> usize {
let mut catalog = Catalog::new();
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
let mut postscript = PostScriptNames::new();
let mut n = 0;
let mut stack = self.font_dirs.clone();
let paths = self.font_paths.get();
let mut modified_at = Vec::new();
let mut files = Vec::new();
let mut stack = paths.clone();
// Each folder once, however links lead back to it.
let mut visited = std::collections::HashSet::new();
while let Some(d) = stack.pop() {
if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) {
continue;
}
let Ok(rd) = std::fs::read_dir(&d) else { continue };
// Before listing it: a font installed meanwhile changes it after this.
modified_at.push((d.clone(), modified(&d)));
// Not a folder: a font file named by itself (or nothing, yet).
let Ok(rd) = std::fs::read_dir(&d) else {
files.push(d);
continue;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
for (k, a) in Alias::of(&family, &style, &keys) {
aliases.entry(k).or_default().push(a);
}
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
for ps in &keys.postscript {
postscript.insert(ps.clone(), (family.clone(), style.clone()));
}
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
entry.traits.get_or_insert(traits);
if entry.name.is_empty() {
entry.name = family;
}
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
n += 1;
} else {
files.push(p);
}
}
}
for p in files {
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
for (k, a) in Alias::of(&family, &style, &keys) {
aliases.entry(k).or_default().push(a);
}
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
for ps in &keys.postscript {
postscript.insert(ps.clone(), (family.clone(), style.clone()));
}
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
entry.traits.get_or_insert(traits);
if entry.name.is_empty() {
entry.name = family;
}
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
n += 1;
}
}
log::debug!("cataloged {n} system font faces");
*self.stamp.lock().unwrap_or_else(|e| e.into_inner()) = Some(ScanStamp { paths, modified: modified_at });
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
+47 -11
View File
@@ -4,7 +4,9 @@ use std::ops::Range;
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
use unicode_bidi::{BidiInfo, Level};
use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject};
use vectorcraft_doc::{
Burasagari, CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject,
};
use vectorcraft_geom::{ArcPath, PathData};
use crate::composer::{Breakpoint, compose};
@@ -749,7 +751,8 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
}
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -> (usize, bool) {
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari) -> (usize, bool) {
if !width.is_finite() {
return (g.len(), false);
}
@@ -759,6 +762,14 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
while j < g.len() {
let gl = &g[j];
if j > i && !gl.is_space() && x + gl.adv > width + EPS {
// It ends the line with the spaces after it.
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, j) && g.get(j + 1).is_none_or(|n| n.byte != gl.byte) {
let mut end = j + 1;
while g.get(end).is_some_and(SGlyph::is_space) {
end += 1;
}
return (end, false);
}
break;
}
x += gl.adv;
@@ -805,6 +816,13 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
(end, hy)
}
/// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width
/// (、。,.) or half width (、。); not a closing bracket, nor Latin punctuation (Latin text keeps
/// its line breaks and composer).
fn hangs(g: &SGlyph) -> bool {
matches!(g.ch, '、' | '。' | ',' | '.' | '、' | '。') && g.tcy.is_none()
}
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
/// with the one before it).
@@ -900,7 +918,10 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
/// line metrics); `None` falls back to the greedy single-line composer.
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> Option<Vec<(usize, bool)>> {
let justified = !matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 {
// A paragraph whose commas or full stops may hang is composed line by line (as the Japanese
// single-line composer does).
let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs);
if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 || may_hang {
return None;
}
let m = Metrics::of(&sg[0]);
@@ -994,7 +1015,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
let width = x1 - x0 - ind_l - para.right_indent;
let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) {
Some(&(e, h)) if e > i => (e, h),
_ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate),
_ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate, para.burasagari),
_ => (n, false),
};
let m = Metrics::max(&sg[i..end]).unwrap_or(pm);
@@ -1038,6 +1059,21 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
None => 0.0,
};
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
// Burasagari: a comma or full stop ending an area type line hangs outside it (Standard:
// when it doesn't fit; Forced: always). The rest of the line is aligned and justified
// without it, and it follows the line's last character.
let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1)
.then(|| sg.get(trimmed - 1))
.flatten()
.filter(|g| hangs(g))
.map(|g| g.adv - end_trim)
.filter(|_| match para.burasagari {
Burasagari::None => false,
Burasagari::Standard => w > width + EPS,
Burasagari::Forced => true,
});
let body = if hang.is_some() { trimmed - 1 } else { trimmed };
let w = w - hang.unwrap_or(0.0);
let (align, justify) = match para.justify {
Justify::Auto => (if rtl { 2 } else { 0 }, false),
Justify::Left => (0, false),
@@ -1050,17 +1086,17 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
};
let justify = justify && regions.is_some();
let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0);
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
let spaces = sg[i..body].iter().filter(|g| g.is_space()).count();
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
let cjk_gap = |j: usize| {
j + 1 < trimmed
j + 1 < body
&& sg
.get(j)
.zip(sg.get(j + 1))
.is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)))
};
let cjk_gaps = (i..trimmed).filter(|&j| cjk_gap(j)).count();
let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count();
if justify && (width - w).abs() > EPS {
if cjk_gaps > 0 && width > w {
// Spread over the CJK gaps and the word spaces alike.
@@ -1069,9 +1105,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
} else if spaces > 0 {
// Composed lines may shrink word spaces (never below zero).
per_space =
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
((width - w) / spaces as f64).max(-sg[i..body].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
} else if para.justify == Justify::JustifyAll && width > w {
let gaps = sg.get(i + 1..trimmed).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
let gaps = sg.get(i + 1..body).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
if gaps > 0 {
per_gap = (width - w) / gaps as f64;
}
@@ -1116,14 +1152,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
}
if g.ch == '\t' {
adv = tab_advance(&para.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
} else if j < trimmed {
} else if j < body {
if g.is_space() {
adv += per_space;
} else if per_cjk != 0.0 {
if cjk_gap(j) {
adv += per_cjk;
}
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
} else if j + 1 < body && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
// Between glyphs, never inside a tate-chu-yoko block (one cell).
adv += per_gap;
}
+20
View File
@@ -585,3 +585,23 @@ fn character_alignment_lines_small_characters_up_with_the_largest_em_box() {
assert!(near(place(CharAlign::EmBoxBottom), -0.12 * 20.0), "bottom: {}", place(CharAlign::EmBoxBottom));
}
}
/// Burasagari leaves Latin punctuation alone: in a measure of exactly "abcd", the full stop of
/// "abcd. ef" doesn't hang with Standard or Forced (the line breaks as it does with None), and a
/// Latin line ending in a full stop isn't shortened by Forced.
#[test]
fn burasagari_leaves_latin_commas_and_full_stops_inside_the_line() {
use vectorcraft_doc::Burasagari;
let measure = width(&point("abcd", style(20.0)));
let lay = |text: &str, b: Burasagari| {
let mut t = area(text, style(20.0), Rect::new(0.0, 0.0, measure + 0.01, 400.0), Justify::JustifyLeft);
t.para.burasagari = b;
layout(db(), &t).glyphs.iter().map(|g| (g.line, (g.origin.x * 100.0).round())).collect::<Vec<_>>()
};
for text in ["abcd. ef", "a b. efgh", "ab, cd, efgh"] {
let none = lay(text, Burasagari::None);
for b in [Burasagari::Standard, Burasagari::Forced] {
assert_eq!(lay(text, b), none, "{text} {b:?}");
}
}
}
+53
View File
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::*;
use crate::fontdb::fonts_outside;
const FAMILY: &str = "Sysfont Sans3";
@@ -184,3 +185,55 @@ fn installed_faces_are_found_by_postscript_name() {
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
}
/// Windows lists fonts installed as shortcuts, or by programs into their own folders, by their
/// files' full paths: those files are scanned too (#443).
#[test]
fn a_font_file_named_by_itself_is_scanned() {
let dir = font_dir("file");
let db = FontDb::with_font_dirs(vec![dir.join("Sub/Sysfont-Bold.TTF"), dir.join("No Such Font.ttf")]);
assert_eq!(db.styles(FAMILY), ["Bold"]);
assert_eq!(db.load_system_fonts(), 1);
}
#[test]
fn registered_fonts_outside_the_font_folders_are_their_full_paths() {
let dir = std::env::temp_dir().join("Fonts");
let elsewhere = std::env::temp_dir().join("Downloads").join("Montserrat-Regular.ttf");
let registered = [
// A file in the Windows font folder, by name.
"arial.ttf".into(),
// A file in a font folder (ignoring case).
dir.join("Lato-Regular.ttf").to_string_lossy().to_uppercase(),
elsewhere.to_string_lossy().into_owned(),
elsewhere.to_string_lossy().into_owned(),
];
assert_eq!(fonts_outside(registered, &[dir]), [elsewhere]);
}
/// Fonts installed while the app runs are found when it comes back to the front, without
/// scanning every time (#443).
#[test]
fn the_database_tells_when_fonts_were_installed_or_removed_since_its_scan() {
let dir = font_dir("changed");
let db = FontDb::with_font_dirs(vec![dir.clone(), dir.join("Later")]);
assert!(!db.installed_fonts_changed(), "nothing to compare with before the first scan");
assert!(!db.has_family("Other Sans 33"));
assert!(!db.installed_fonts_changed());
// Past the file system's clock tick, so the folders' times differ from the scan's.
let tick = || std::thread::sleep(std::time::Duration::from_millis(50));
tick();
std::fs::write(dir.join("Sub/Other.ttf"), renamed_to("SourceSans3-Regular.ttf", "Other Sans 33")).unwrap();
assert!(db.installed_fonts_changed(), "a font installed into a subfolder");
assert_eq!(db.load_system_fonts(), 3);
assert!(db.has_family("Other Sans 33") && !db.installed_fonts_changed());
tick();
std::fs::create_dir_all(dir.join("Later")).unwrap();
assert!(db.installed_fonts_changed(), "a font folder that didn't exist");
db.load_system_fonts();
tick();
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
assert!(db.installed_fonts_changed(), "a font removed");
assert_eq!(db.load_system_fonts(), 2);
assert_eq!(db.styles(FAMILY), ["Bold"]);
}
+16
View File
@@ -262,6 +262,22 @@ fn every_line_composer_is_more_even() {
assert!(kp <= greedy + 1e-6, "every-line worst space {kp} vs greedy {greedy}");
}
/// Burasagari (new type's Standard) leaves Latin paragraphs to the every-line composer: only a
/// paragraph with a Japanese comma or full stop is composed line by line.
#[test]
fn burasagari_keeps_the_every_line_composer_for_latin_text() {
// A measure where the two composers break the copy differently.
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, 300.0, 4000.0), Justify::JustifyLeft);
let ends = |t: &TextObject, c: Composer| {
let l = layout_with(db(), t, &LayoutOptions { composer: c, ..Default::default() });
l.lines.iter().map(|li| li.glyph_end).collect::<Vec<_>>()
};
let every_line = ends(&t, Composer::EveryLine);
assert_ne!(every_line, ends(&t, Composer::SingleLine));
t.para.burasagari = vectorcraft_doc::Burasagari::Standard;
assert_eq!(ends(&t, Composer::EveryLine), every_line);
}
#[test]
fn hyphenation_rules() {
assert_eq!(hyphen::hyphenate_word("typography"), "typo-gra-phy");
+63
View File
@@ -406,3 +406,66 @@ fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
assert!((first.glyphs[0].advance - 20.0).abs() < 0.01, "vertical {vertical_type}: {}", first.glyphs[0].advance);
}
}
/// Burasagari: in a measure of exactly five ems, a comma that would be the sixth character goes to
/// the next line with the character before it (None) or hangs outside the line (Standard, Forced);
/// a full stop that is the fifth character stays inside (None, Standard) or hangs while the four
/// before it fill the measure (Forced). Closing brackets don't hang. Horizontal and vertical, with
/// Line-end Punctuation Half Width (the hanging mark is half width). Needs a font with full-width
/// Japanese punctuation (skipped without one).
#[test]
fn burasagari_hangs_a_comma_or_full_stop_outside_the_line() {
use vectorcraft_doc::{Burasagari, Mojikumi};
// 20 pt type, justified, in a frame five ems along the lines.
let lay = |text: &str, b: Burasagari, vertical_type: bool| {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = Mojikumi::LineEndHalf;
t.para.justify = Justify::JustifyLeft;
t.para.burasagari = b;
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 100.0) } else { Rect::new(0.0, 0.0, 100.0, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(FontDb::global(), &t)
};
if (lay("一、二", Burasagari::None, false).glyphs[1].advance - 20.0).abs() > 2.0 {
return; // no font with full-width punctuation here
}
let advances = |l: &TextLayout, n: usize| l.glyphs.iter().take(n).map(|g| (g.advance * 100.0).round() / 100.0).collect::<Vec<_>>();
for vertical_type in [false, true] {
// 、 as the sixth character.
let none = lay("一二三四五、六七", Burasagari::None, vertical_type);
assert_ne!(none.glyphs[4].line, none.glyphs[0].line, "vertical {vertical_type}: 五、 go to the next line");
for b in [Burasagari::Standard, Burasagari::Forced] {
let l = lay("一二三四五、六七", b, vertical_type);
assert_eq!(l.glyphs[5].line, l.glyphs[0].line, "vertical {vertical_type} {b:?}: 、 hangs");
assert_eq!(advances(&l, 6), [20.0, 20.0, 20.0, 20.0, 20.0, 10.0], "vertical {vertical_type} {b:?}: five ems inside, half an em outside");
assert_eq!(l.glyphs[6].line, l.glyphs[0].line + 1, "vertical {vertical_type} {b:?}");
}
// 。 as the fifth character: it fits.
for b in [Burasagari::None, Burasagari::Standard] {
let l = lay("一二三四。六七", b, vertical_type);
assert_eq!(l.glyphs[5].line, l.glyphs[0].line + 1, "vertical {vertical_type} {b:?}");
// Four ems and a half: the half em left is spread between the five characters.
assert_eq!(advances(&l, 5), [22.5, 22.5, 22.5, 22.5, 10.0], "vertical {vertical_type} {b:?}: 。 inside");
}
let forced = lay("一二三四。六七", Burasagari::Forced, vertical_type);
let a = advances(&forced, 5);
assert!((a[..4].iter().sum::<f64>() - 100.0).abs() < 0.05, "vertical {vertical_type}: the four fill the measure: {a:?}");
assert_eq!(a[4], 10.0, "vertical {vertical_type}");
assert_eq!(forced.glyphs[4].line, forced.glyphs[0].line);
// A closing bracket doesn't hang: 」 as the sixth character goes on with 五.
for b in [Burasagari::Standard, Burasagari::Forced] {
let l = lay("一二三四五」六七", b, vertical_type);
assert_ne!(l.glyphs[4].line, l.glyphs[0].line, "vertical {vertical_type} {b:?}");
}
// The full-width comma and full stop hang too.
for mark in [',', '.'] {
let l = lay(&format!("一二三四五{mark}六七"), Burasagari::Standard, vertical_type);
assert_eq!(l.glyphs[5].line, l.glyphs[0].line, "vertical {vertical_type}: {mark} hangs");
}
}
// Horizontal: the hanging mark starts at the frame's edge.
let l = lay("一二三四五、六七", Burasagari::Standard, false);
assert!((l.glyphs[5].origin.x - 100.0).abs() < 0.01, "{:?}", l.glyphs[5].origin);
}
+181 -56
View File
@@ -162,12 +162,7 @@ fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Sha
continue;
}
for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) {
let mut closed = sp.clone();
closed.closed = true;
let mut bp = BezPath::new();
closed.to_bezpath_into(&mut bp);
let size = bp.bounding_box().size();
sp.closed = bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height);
sp.closed = po::encloses_area(sp);
}
}
(shapes, leaves)
@@ -195,6 +190,9 @@ pub struct BuilderMap {
pub regions: Vec<Face>,
/// Pieces of the unfilled open paths, keyed by input index, with their bounds.
pub lines: Vec<(po::Shape, Rect)>,
/// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with
/// their bounds: the edges erasing deletes. Only in a map made `with_edges`.
pub edges: Vec<(po::Shape, Rect)>,
}
/// What a Shape Builder gesture touches at a point.
@@ -202,18 +200,43 @@ pub struct BuilderMap {
pub enum Hit {
Region(usize),
Line(usize),
Edge(usize),
}
/// The piece of `pieces` nearest `p` within `tol`, with its distance.
fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, usize)> {
let mut best: Option<(f64, usize)> = None;
for (i, (l, b)) in pieces.iter().enumerate() {
if b.inflate(tol, tol).contains(p)
&& let Some((.., d)) = l.path.nearest(p)
&& d <= tol
&& best.is_none_or(|b| d < b.0)
{
best = Some((d, i));
}
}
best
}
impl BuilderMap {
/// The map of `roots` (sorted back → front), with the leaves the inputs came from.
pub fn new<'a>(doc: &'a Document, roots: &[NodeId]) -> (Self, Vec<&'a Node>) {
/// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline
/// edges only `with_edges` (they cost a planar map of their own when no open path is in).
pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool) -> (Self, Vec<&'a Node>) {
let (shapes, leaves) = shape_builder_shapes(doc, roots);
if shapes.is_empty() {
return (Self::default(), leaves);
}
let (regions, lines) = po::shape_builder(&shapes);
let lines = lines.into_iter().filter_map(|l| Some((l.path.bounds()?, l))).map(|(b, l)| (l, b)).collect();
(Self { shapes, regions: faces_of(regions), lines }, leaves)
let arr = po::shape_builder(&shapes, with_edges);
let bounded = |pieces: Vec<po::Shape>| {
pieces
.into_iter()
.filter_map(|l| {
let b = l.path.bounds()?;
Some((l, b))
})
.collect()
};
(Self { shapes, regions: faces_of(arr.regions), lines: bounded(arr.lines), edges: bounded(arr.edges) }, leaves)
}
/// The region containing `p`.
@@ -221,53 +244,56 @@ impl BuilderMap {
self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0)
}
/// The line piece nearest `p`, within `tol`.
pub fn line_near(&self, p: Point, tol: f64) -> Option<usize> {
let mut best: Option<(f64, usize)> = None;
for (i, (l, b)) in self.lines.iter().enumerate() {
if b.inflate(tol, tol).contains(p)
&& let Some((.., d)) = l.path.nearest(p)
&& d <= tol
&& best.is_none_or(|b| d < b.0)
{
best = Some((d, i));
}
}
best.map(|b| b.1)
/// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that
/// close, else the region under it.
pub fn hit(&self, p: Point, piece_tol: Option<f64>) -> Option<Hit> {
let piece = piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) {
(Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)),
(Some(l), _) => Some(Hit::Line(l.1)),
(None, e) => e.map(|e| Hit::Edge(e.1)),
});
piece.or_else(|| self.region_at(p).map(Hit::Region))
}
/// What a gesture at `p` touches: with `line_tol` (erasing), a line piece that close, else the
/// region under it.
pub fn hit(&self, p: Point, line_tol: Option<f64>) -> Option<Hit> {
line_tol.and_then(|t| self.line_near(p, t)).map(Hit::Line).or_else(|| self.region_at(p).map(Hit::Region))
}
/// Does line piece `i` run between two of `regions` (an edge that merging or deleting them
/// removes)?
pub fn line_between(&self, i: usize, regions: &[usize]) -> bool {
let Some(sp) = self.lines.get(i).and_then(|(l, _)| l.path.subpaths.first()) else { return false };
/// The regions just either side of the middle of `piece` (None: outside every region).
fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
let Some(sp) = piece.path.subpaths.first() else { return [None; 2] };
let n = sp.segment_count();
if n == 0 {
return false;
return [None; 2];
}
let c = sp.segment(n / 2);
use kurbo::{ParamCurve as _, ParamCurveDeriv as _};
let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2());
let len = d.hypot();
if !(len.is_finite() && len > 0.0) {
return false;
return [None; 2];
}
// A little to each side: inside the faces next to it, which share it exactly.
let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len);
[m + off, m - off].into_iter().all(|q| self.region_at(q).is_some_and(|r| regions.contains(&r)))
[m + off, m - off].map(|q| self.region_at(q))
}
/// Does line piece `i` run between two of `regions` (an edge that merging or deleting them
/// removes)?
pub fn line_between(&self, i: usize, regions: &[usize]) -> bool {
self.lines.get(i).is_some_and(|(l, _)| self.sides(l).iter().all(|r| r.is_some_and(|r| regions.contains(&r))))
}
/// The edges `touched` deletes: those that bound none of its regions (deleting a region takes
/// its edges with it, so an erase drag that crosses an edge into a region deletes the region).
pub fn erased_edges(&self, touched: &Touched) -> Vec<usize> {
let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r)));
touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect()
}
}
/// The regions and line pieces a Shape Builder gesture has touched, in the order touched.
/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Touched {
pub regions: Vec<usize>,
pub lines: Vec<usize>,
pub edges: Vec<usize>,
}
impl Touched {
@@ -275,6 +301,7 @@ impl Touched {
let (list, i) = match hit {
Some(Hit::Region(i)) => (&mut self.regions, i),
Some(Hit::Line(i)) => (&mut self.lines, i),
Some(Hit::Edge(i)) => (&mut self.edges, i),
None => return,
};
if !list.contains(&i) {
@@ -283,7 +310,7 @@ impl Touched {
}
pub fn is_empty(&self) -> bool {
self.regions.is_empty() && self.lines.is_empty()
self.regions.is_empty() && self.lines.is_empty() && self.edges.is_empty()
}
}
@@ -381,36 +408,42 @@ struct RegionCache {
map: BuilderMap,
/// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions.
live: bool,
/// The map has the Shape Builder's outline edges.
edges: bool,
}
impl RegionCache {
fn live_paint() -> Self {
Self { live: true, ..Default::default() }
}
fn get(&mut self, cx: &ToolContext) -> &BuilderMap {
/// The map, with the outline edges when `edges` (built the first time they're wanted).
fn get(&mut self, cx: &ToolContext, edges: bool) -> &BuilderMap {
let key = (cx.doc as *const Document as usize, cx.selection.objects.clone());
if self.key.as_ref() != Some(&key) {
if self.key.as_ref() != Some(&key) || (edges && !self.edges) {
let roots = sorted_roots(cx.doc, &cx.selection.objects);
self.map = if self.live {
let (shapes, _) = shapes_for(cx.doc, &roots);
let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) };
BuilderMap { regions, ..Default::default() }
} else {
BuilderMap::new(cx.doc, &roots).0
BuilderMap::new(cx.doc, &roots, edges).0
};
self.key = Some(key);
self.edges = edges;
}
&self.map
}
fn at(&mut self, cx: &ToolContext, p: Point) -> Option<usize> {
self.get(cx).region_at(p)
self.get(cx, false).region_at(p)
}
}
// ---------- Shape Builder ----------
/// Shape Builder tool: drag across regions to merge them, Alt-drag to delete them (and the pieces
/// of open paths it passes over).
/// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase
/// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in
/// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges
/// (outline pieces between the points where paths meet) it passes over.
#[derive(Default)]
pub struct ShapeBuilderTool {
cache: RegionCache,
@@ -418,10 +451,11 @@ pub struct ShapeBuilderTool {
points: Vec<Point>,
touched: Touched,
dragging: bool,
/// Erase mode: Alt held while hovering, or when the drag started.
alt: bool,
}
/// How close (screen px) the pointer must come to a line piece to delete it.
/// How close (screen px) the pointer must come to a line piece or an edge to delete it.
const LINE_TOL_PX: f64 = 4.0;
impl ShapeBuilderTool {
@@ -435,9 +469,9 @@ impl ShapeBuilderTool {
None => vec![to],
};
let tol = Self::line_tol(cx, self.alt);
let map = self.cache.get(cx, self.alt);
for p in pts {
let hit = self.cache.get(cx).hit(p, tol);
self.touched.add(hit);
self.touched.add(map.hit(p, tol));
}
}
}
@@ -451,7 +485,8 @@ impl Tool for ShapeBuilderTool {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = self.cache.get(cx).hit(p, Self::line_tol(cx, ev.mods.alt));
self.alt = ev.mods.alt;
self.hover = self.cache.get(cx, self.alt).hit(p, Self::line_tol(cx, self.alt));
vec![]
}
PointerKind::Down => {
@@ -487,7 +522,8 @@ impl Tool for ShapeBuilderTool {
self.cache.key = None;
let roots = sorted_roots(cx.doc, &cx.selection.objects);
let pts: Vec<Value> = points.iter().map(|q| json!([q.x, q.y])).collect();
let erase = self.alt || ev.mods.alt;
// The mode the drag started in, which its highlight showed.
let erase = self.alt;
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase });
if let Some(t) = Self::line_tol(cx, erase) {
params["tolerance"] = json!(t);
@@ -501,9 +537,11 @@ impl Tool for ShapeBuilderTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let map = &self.cache.map;
let color = if self.dragging && self.alt { HIGHLIGHT_RED } else { HATCH };
let color = if self.alt { HIGHLIGHT_RED } else { HATCH };
let show: Vec<Hit> = if self.dragging {
self.touched.regions.iter().map(|&i| Hit::Region(i)).chain(self.touched.lines.iter().map(|&i| Hit::Line(i))).collect()
let t = &self.touched;
let regions = t.regions.iter().map(|&i| Hit::Region(i));
regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect()
} else {
self.hover.into_iter().collect()
};
@@ -515,8 +553,9 @@ impl Tool for ShapeBuilderTool {
out.push(Overlay::Path { path: bp.clone(), color, width: 1.0, dashed: false });
}
}
Hit::Line(i) => {
if let Some((l, _)) = map.lines.get(i) {
Hit::Line(i) | Hit::Edge(i) => {
let pieces = if matches!(hit, Hit::Line(_)) { &map.lines } else { &map.edges };
if let Some((l, _)) = pieces.get(i) {
out.push(Overlay::Path { path: l.path.to_bezpath(), color: HIGHLIGHT_RED, width: 3.0, dashed: false });
}
}
@@ -533,8 +572,10 @@ impl Tool for ShapeBuilderTool {
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
// A drag keeps the mode it started in; hovering follows the key.
let erase = if self.dragging { self.alt } else { m.alt };
if erase { Cursor::ShapeBuilderErase } else { Cursor::ShapeBuilder }
}
fn busy(&self) -> bool {
@@ -803,6 +844,90 @@ mod tests {
assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty());
}
/// Squares A (0..100) and B (50..150 both ways), overlapping at a corner, selected.
fn corner_rects() -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for (k, r) in [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(50.0, 50.0, 150.0, 150.0)].into_iter().enumerate() {
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(r), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
}
fn colors(ov: &[Overlay]) -> Vec<[u8; 3]> {
ov.iter().map(|o| if let Overlay::Path { color, .. } = o { *color } else { panic!("{o:?}") }).collect()
}
#[test]
fn shape_builder_alt_switches_to_erase_mode_while_held() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
let at = Point::new(75.0, 75.0);
// Merge mode: a grey mesh and the plus pointer.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
// Alt held: what would go highlights red, and the pointer shows a minus.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y).with_mods(alt));
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 2]);
assert_eq!(t.cursor(&cx, at, alt), Cursor::ShapeBuilderErase);
// Released: merge mode again.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
// A drag keeps the mode it started in.
t.pointer(&cx, &ev(PointerKind::Down, at.x, at.y).with_mods(alt));
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilderErase);
let acts = t.pointer(&cx, &ev(PointerKind::Up, at.x, at.y));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(v["erase"], true);
}
#[test]
fn shape_builder_alt_on_an_edge_highlights_and_deletes_it() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
// A's side inside B, between the points where B's outline crosses it.
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt));
assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover);
let ov = t.overlays(&cx);
let [Overlay::Path { path, color: HIGHLIGHT_RED, width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(50.0, 50.0, 100.0, 100.0));
// Without Alt it's the region under the pointer.
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0));
assert!(matches!(t.hover, Some(Hit::Region(_))));
t.pointer(&cx, &ev(PointerKind::Down, 101.0, 75.0).with_mods(alt));
assert_eq!(t.touched.edges.len(), 1);
let acts = t.pointer(&cx, &ev(PointerKind::Up, 101.0, 75.0).with_mods(alt));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0)));
}
#[test]
fn shape_builder_erase_drag_into_regions_shows_the_regions_not_the_edges_it_crosses() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0).with_mods(alt));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(alt));
assert_eq!(t.touched.regions.len(), 2);
assert!(!t.touched.edges.is_empty(), "the drag crossed B's outline");
assert!(t.cache.map.erased_edges(&t.touched).is_empty(), "which goes with the regions");
// Two regions (mesh + outline each) and the drag's trail, all red.
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]);
}
#[test]
fn hatch_stays_inside_region() {
let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath();
+100 -15
View File
@@ -7,8 +7,8 @@
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2};
use crate::{Action, Overlay, PointerEvent, ToolContext};
@@ -47,18 +47,20 @@ impl CornerWidgets {
return None;
}
let Some(NodeKind::Path { live: Some(live), .. }) = doc.node(id).map(|n| &n.kind) else { return None };
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live else { return None };
let (w, h, radii, kinds, xf) = (*w, *h, *radii, *kinds, *xf);
// In document units, as `object.setLiveShape` edits them.
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live.folded() else { return None };
// Screen pixels per shape unit along each side (the shape may be scaled or skewed).
let c = xf.as_coeffs();
let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom);
if xf.determinant().abs() < 1e-12 || (w * px).min(h * py) < MIN_SIDE_PX {
return None;
}
// Each widget sits on its corner's diagonal, at the centre of the arc as drawn.
let points = std::array::from_fn(|k| {
let ((fx, fy), (sx, sy)) = CORNERS[k];
let ix = radii[k].max(MIN_INSET_PX / px).min(w / 2.0);
let iy = radii[k].max(MIN_INSET_PX / py).min(h / 2.0);
let r = shapes::fitted_corner_radius(w, h, radii[k]);
let ix = r.max(MIN_INSET_PX / px).min(w / 2.0);
let iy = r.max(MIN_INSET_PX / py).min(h / 2.0);
xf * Point::new(fx * w + sx * ix, fy * h + sy * iy)
});
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
@@ -138,6 +140,8 @@ pub struct CornerDrag {
began: bool,
alt: bool,
radius: f64,
/// The pointer went past fully round: the corners are at their largest radius.
at_limit: bool,
at: Point,
}
@@ -146,18 +150,41 @@ impl CornerDrag {
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
let (widgets, p) = (CornerWidgets::for_tool(cx)?, ev.pos);
let corner = widgets.hit(p, cx.tol(5.0))?;
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at: p })
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at_limit: false, at: p })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's inward diagonal (in the shape's own units), from square to fully round.
fn radius_at(&self, p: Point) -> f64 {
/// corner's inward diagonal (in the shape's own units), from square to fully round; and
/// whether the travel reached fully round (the limit every corner shares).
fn radius_at(&self, p: Point) -> (f64, bool) {
let w = &self.widgets;
let inv = w.xf.inverse();
let d = inv * p - inv * self.start;
let (_, (sx, sy)) = CORNERS[self.corner];
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
let max = shapes::max_corner_radius(w.w, w.h);
let r = shapes::fitted_corner_radius(w.w, w.h, w.radii[self.corner]) + (d.x * sx + d.y * sy) / 2.0;
(r.min(max).max(0.0), max > 0.0 && r >= max)
}
/// The edited corners' cuts at the dragged radius, in document coordinates.
fn edited_corners(&self) -> BezPath {
let w = &self.widgets;
let rect = Rect::new(0.0, 0.0, w.w, w.h);
let radii = std::array::from_fn(|k| if w.shown[k] { self.radius } else { w.radii[k] });
let corner_of = shapes::rectangle_anchor_corners(rect, radii);
let path = shapes::rectangle_with_corners(rect, radii, w.kinds).transformed(w.xf);
let mut out = BezPath::new();
let Some(sp) = path.subpaths.first() else { return out };
// A corner's cut is the segment between its two anchors (the last one, for the top-left).
for i in 0..sp.segment_count() {
let (a, b) = (corner_of.get(i), corner_of.get(i + 1).or(corner_of.first()));
if a == b && a.is_some_and(|k| w.shown.get(*k) == Some(&true)) {
let c = sp.segment(i);
out.move_to(c.p0);
out.curve_to(c.p1, c.p2, c.p3);
}
}
out
}
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
@@ -169,7 +196,7 @@ impl CornerDrag {
self.began = true;
out.push(Action::Begin("Corner Radius".into()));
}
self.radius = self.radius_at(p);
(self.radius, self.at_limit) = self.radius_at(p);
self.at = p;
out.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius))));
out
@@ -186,12 +213,16 @@ impl CornerDrag {
}
}
/// The radius readout next to the pointer.
/// The radius readout next to the pointer; at the largest radius, the edited corners in red.
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if !self.began {
return vec![];
}
vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }]
let mut out = vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }];
if self.at_limit {
out.push(Overlay::Path { path: self.edited_corners(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
}
out
}
}
@@ -204,10 +235,15 @@ mod tests {
/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`.
fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) {
live_shape(100.0, 100.0, r, xf)
}
/// A live `w` × `h` rectangle with corner radius `r`.
fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [r; 4], kinds: Default::default(), xf };
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
@@ -264,6 +300,55 @@ mod tests {
}
}
/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also
/// with a radius past half the shorter side; a drag past fully round stops every corner there
/// and outlines them in red.
#[test]
fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() {
let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0)));
let s = selected(id);
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)];
assert_eq!(w.points, centres);
let p = paint();
let cx = cx(&d, &s, &p);
let red = |t: &dyn crate::Tool| {
t.overlays(&cx).into_iter().find_map(|o| match o {
Overlay::Path { path, color, .. } if color == crate::builder::HIGHLIGHT_RED => Some(path),
_ => None,
})
};
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 140.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 200.0));
assert_eq!(radius(&a[1]), 40.0);
// The four arcs, each a quarter circle of radius 40 starting on a side.
let arcs = red(t.as_ref()).expect("the limit shows");
let starts: Vec<_> =
arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]);
// Back below the limit, the outline goes.
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0);
assert!(red(t.as_ref()).is_none());
}
/// #442: a rectangle saved stretched (an uneven scale in its transform, before #291) shows its
/// widgets and drags its radius in document units, as `object.setLiveShape` sets it.
#[test]
fn a_stretched_rectangle_drags_its_radius_in_document_units() {
// 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20.
let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0));
let s = selected(id);
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
assert_eq!(w.points[0], Point::new(120.0, 120.0));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0, 120.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 110.0));
assert!((radius(&a[1]) - 10.0).abs() < 1e-9, "{a:?}");
}
#[test]
fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
// Rotated 90°: the shape's top-left corner is at document (200, 100).
+1 -1
View File
@@ -655,7 +655,7 @@ mod tests {
#[test]
fn direct_and_group_selection_pick_ruler_guides() {
let (mut d, id) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide { vertical: true, pos: 100.0 });
d.guides.push(vectorcraft_doc::Guide::new(true, 100.0));
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y));
+1
View File
@@ -15,6 +15,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub(crate) use anchor::hit_handle;
pub use curvature::CurvatureTool;
pub use family::FamilyTool;
pub use gesture::GestureTool;
+5 -3
View File
@@ -49,11 +49,13 @@ impl AnchorTool {
}
}
/// A direction handle of a selected path under `p`: (id, subpath, anchor, is_out).
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
/// A direction handle of a selected path under `p`, among those shown (a path with anchors
/// direct-selected shows theirs only): (id, subpath, anchor, is_out).
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, ai, a) in pd.anchors() {
let shown = cx.selection.partial(*id);
for (si, ai, a) in pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))) {
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
+1 -1
View File
@@ -186,7 +186,7 @@ impl Tool for FamilyTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match self.start {
Some(s) if self.began => {
Some(s) if self.began && cx.measurement_labels => {
let d = self.last - s;
vec![Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) }]
}
+164 -36
View File
@@ -1,6 +1,7 @@
//! Smart Guides: snapping to anchors, path segments (for dragged anchors), object bounds
//! (edges/centres), artboards, with the magenta construction lines and labels Illustrator users
//! expect.
//! expect. Preferences › Smart Guides sets their colour, which of them show ([`GuideStyle`]) and
//! how near a target pulls (`ToolContext::snapping_tolerance`).
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, Selection};
@@ -12,6 +13,30 @@ use crate::{Overlay, ToolContext};
pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc];
/// How Smart Guides look (Preferences › Smart Guides › Display Options): their colour, and whether
/// the alignment lines and the anchor/path labels show. Snapping is the same either way.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GuideStyle {
pub color: [u8; 3],
/// Alignment Guides: the lines along the edges and centres the art lines up with.
pub lines: bool,
/// Anchor/Path Labels: "anchor", "center", "path", "align"…
pub labels: bool,
}
impl Default for GuideStyle {
fn default() -> Self {
Self { color: MAGENTA, lines: true, labels: true }
}
}
impl GuideStyle {
/// The style the preferences in `cx` ask for.
pub fn of(cx: &ToolContext) -> Self {
Self { color: cx.smart_guide_color, lines: cx.alignment_guides, labels: cx.anchor_path_labels }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Anchor,
@@ -44,6 +69,34 @@ pub struct Targets {
/// Path segments and their control bounds, which a point lands on ("path"): gathered only for
/// dragged anchors ([`Self::for_anchor_drag`]).
segments: Vec<(Rect, PathSeg)>,
/// Ruler guides, which pull a point into line where they run (Snap to Point).
rulers: Vec<Ruler>,
/// How the guides these targets produce look.
style: GuideStyle,
}
/// A ruler guide as a snap target.
struct Ruler {
vertical: bool,
pos: f64,
/// Where it runs along its line: an artboard guide across its artboard (see
/// [`vectorcraft_doc::Document::guide_span`]), else (None) the whole canvas.
span: Option<(f64, f64)>,
}
impl Ruler {
/// The line it pulls `p` into within `tol` of its ends (its position, where it starts, the
/// kind), as [`Targets::xs`] holds them: none where it doesn't run.
fn line_at(&self, p: Point, tol: f64) -> Option<(f64, Point, Kind)> {
let along = if self.vertical { p.y } else { p.x };
let start = match self.span {
Some((a, b)) if along < a - tol || along > b + tol => return None,
Some((a, _)) => a,
None => 0.0,
};
let from = if self.vertical { Point::new(self.pos, start) } else { Point::new(start, self.pos) };
Some((self.pos, from, Kind::Guide))
}
}
/// How many anchors, and how many segments, the targets gather at most.
@@ -54,6 +107,23 @@ impl Targets {
Self::collect_inner(doc, exclude, None, visible, false)
}
/// The same targets drawing their guides as the Smart Guides preferences in `cx` ask
/// ([`GuideStyle::of`]).
pub fn styled(mut self, cx: &ToolContext) -> Self {
self.style = GuideStyle::of(cx);
self
}
/// An alignment line from `a` to `b`, unless Alignment Guides are off.
fn line(&self, a: Point, b: Point) -> Option<Overlay> {
self.style.lines.then_some(Overlay::Line { a, b, color: self.style.color, dashed: false })
}
/// A label `text` at `p`, unless Anchor/Path Labels are off.
fn label(&self, p: Point, text: &str) -> Option<Overlay> {
self.style.labels.then(|| Overlay::Label { p, text: text.into(), color: self.style.color })
}
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
/// art that moves along with it).
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
@@ -151,22 +221,25 @@ impl Targets {
t
}
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
/// unlocked, as the selection tools pick them) pull the pointer into line.
fn for_snap_to_point(mut self, cx: &ToolContext) -> Self {
/// Only these targets' anchors pull (View → Snap to Point).
pub(crate) fn anchors_only(mut self) -> Self {
self.points.retain(|(_, k)| *k == Kind::Anchor);
self.xs.clear();
self.ys.clear();
self.segments.clear();
if cx.guides {
for g in &cx.doc.guides {
let (lines, at) = if g.vertical { (&mut self.xs, Point::new(g.pos, 0.0)) } else { (&mut self.ys, Point::new(0.0, g.pos)) };
lines.push((g.pos, at, Kind::Guide));
}
}
self
}
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
/// unlocked, as the selection tools pick them) pull the pointer into line where they run.
fn for_snap_to_point(self, cx: &ToolContext) -> Self {
let mut t = self.anchors_only();
if cx.guides {
t.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
}
t
}
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
/// Guides off: the anchors of the art but `exclude` and the ruler guides.
pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option<Self> {
@@ -199,27 +272,32 @@ impl Targets {
/// into line with targets. Returns the snapped point and guide overlays.
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) {
return (*q, vec![Overlay::Label { p: *q, text: k.label().into(), color: MAGENTA }]);
return (*q, self.label(*q, k.label()).into_iter().collect());
}
if let Some(q) = self.on_segment(p, tol) {
return (q, vec![Overlay::Label { p: q, text: Kind::Edge.label().into(), color: MAGENTA }]);
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
}
let mut out = p;
let mut ov = vec![];
if let Some((x, from, _)) =
self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs()))
{
out.x = *x;
ov.push(Overlay::Line { a: *from, b: Point::new(*x, p.y), color: MAGENTA, dashed: false });
// The targets' lines along one axis and the ruler guides running past `p`, the nearest
// within reach of `v`.
let nearest = |lines: &[(f64, Point, Kind)], vertical: bool, v: f64| {
let rulers = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(p, tol));
lines.iter().copied().chain(rulers).filter(|(t, ..)| (t - v).abs() <= tol).min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()))
};
let mut aligned = false;
if let Some((x, from, _)) = nearest(&self.xs, true, p.x) {
out.x = x;
aligned = true;
ov.extend(self.line(from, Point::new(x, p.y)));
}
if let Some((y, from, _)) =
self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs()))
{
out.y = *y;
ov.push(Overlay::Line { a: *from, b: Point::new(p.x, *y), color: MAGENTA, dashed: false });
if let Some((y, from, _)) = nearest(&self.ys, false, p.y) {
out.y = y;
aligned = true;
ov.extend(self.line(from, Point::new(p.x, y)));
}
if !ov.is_empty() {
ov.push(Overlay::Label { p: out, text: "align".into(), color: MAGENTA });
if aligned {
ov.extend(self.label(out, "align"));
}
(out, ov)
}
@@ -247,7 +325,7 @@ impl Targets {
.filter(|(t, ..)| (t - v).abs() <= tol)
.min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()));
match best {
Some((t, from, k)) => (t, vec![Overlay::Label { p: from, text: k.label().into(), color: MAGENTA }]),
Some((t, from, k)) => (t, self.label(from, k.label()).into_iter().collect()),
None => (v, vec![]),
}
}
@@ -260,10 +338,10 @@ impl Targets {
return self.snap_point(p, tol);
}
let rects: Vec<Rect> = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect();
let (adj, mut ov) = self.snap_rects(&rects, tol);
let (adj, mut ov, aligned) = self.align_rects(&rects, tol);
let out = p + adj;
if !ov.is_empty() {
ov.push(Overlay::Label { p: out, text: "align".into(), color: MAGENTA });
if aligned {
ov.extend(self.label(out, "align"));
}
(out, ov)
}
@@ -276,6 +354,13 @@ impl Targets {
/// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any
/// of them nearest a target, per axis. Returns the shift and the guides.
pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>) {
let (d, ov, _) = self.align_rects(rects, tol);
(d, ov)
}
/// [`Self::snap_rects`], also saying whether an axis lined up (even with Alignment Guides
/// off, or already in line, when there is no line or shift to tell).
fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>, bool) {
let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| {
rects
.iter()
@@ -292,15 +377,15 @@ impl Targets {
d.x = dx;
let x = x + dx;
let (y0, y1) = (from.y.min(r.y0), from.y.max(r.y1));
ov.push(Overlay::Line { a: Point::new(x, y0), b: Point::new(x, y1), color: MAGENTA, dashed: false });
ov.extend(self.line(Point::new(x, y0), Point::new(x, y1)));
}
if let Some((dy, from, y, r)) = best_y {
d.y = dy;
let y = y + dy;
let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1));
ov.push(Overlay::Line { a: Point::new(x0, y), b: Point::new(x1, y), color: MAGENTA, dashed: false });
ov.extend(self.line(Point::new(x0, y), Point::new(x1, y)));
}
(d, ov)
(d, ov, best_x.is_some() || best_y.is_some())
}
/// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for
@@ -388,10 +473,10 @@ impl Targets {
let nr = (to_doc * out).transform_rect_bbox(r);
let mut ov = vec![];
if let Some((x, from)) = hit_x {
ov.push(Overlay::Line { a: Point::new(x, from.y.min(nr.y0)), b: Point::new(x, from.y.max(nr.y1)), color: MAGENTA, dashed: false });
ov.extend(self.line(Point::new(x, from.y.min(nr.y0)), Point::new(x, from.y.max(nr.y1))));
}
if let Some((y, from)) = hit_y {
ov.push(Overlay::Line { a: Point::new(from.x.min(nr.x0), y), b: Point::new(from.x.max(nr.x1), y), color: MAGENTA, dashed: false });
ov.extend(self.line(Point::new(from.x.min(nr.x0), y), Point::new(from.x.max(nr.x1), y)));
}
(out, ov)
}
@@ -421,7 +506,7 @@ impl PointSnap {
let targets = if cx.snap_to_pixel || cx.snap_to_grid {
None
} else if cx.smart_guides {
Some((targets(), 5.0))
Some((targets().styled(cx), cx.snapping_tolerance))
} else if cx.snap_to_point {
Some((targets().for_snap_to_point(cx), cx.snap_tolerance))
} else {
@@ -472,11 +557,11 @@ pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
if !(cx.snap_to_point || cx.smart_guides) {
return (p, vec![]);
}
let tol = cx.tol(if cx.smart_guides { 5.0 } else { cx.snap_tolerance });
let tol = if cx.smart_guides { cx.snap_tol() } else { cx.tol(cx.snap_tolerance) };
let mut roots = cx.selection.objects.clone();
roots.extend(hit_test(cx.doc, p, HitOptions { tol, ..cx.hit_options() }).map(|h| h.leaf));
let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol);
Targets::points_near(cx.doc, &roots, near).snap_point(p, tol)
Targets::points_near(cx.doc, &roots, near).styled(cx).snap_point(p, tol)
}
#[cfg(test)]
@@ -549,6 +634,49 @@ mod tests {
assert!(ov.is_empty());
}
/// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and
/// Anchor/Path Labels off, change what shows and never where a point lands.
#[test]
fn display_options_colour_and_hide_the_guides_but_keep_the_snap() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let green = [0x00, 0xff, 0x00];
let c = ToolContext { smart_guide_color: green, ..cx(&d, &s, &p) };
let t = Targets::collect(&d, &[], None).styled(&c);
let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0);
assert_eq!(q, Point::new(100.0, 100.0));
assert!(matches!(&ov[0], Overlay::Label { text, color, .. } if text == "anchor" && *color == green), "{ov:?}");
let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0);
assert_eq!(q.y, 200.0);
assert!(ov.iter().all(|o| matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color == green)), "{ov:?}");
assert!(
ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align"))
);
// Alignment Guides off: the point still lines up and says so, without the line.
let c = ToolContext { alignment_guides: false, ..cx(&d, &s, &p) };
let t = Targets::collect(&d, &[], None).styled(&c);
let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0);
assert_eq!(q.y, 200.0);
assert!(!ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "{ov:?}");
assert!(ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")), "{ov:?}");
let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0);
assert_eq!((dv.x, ov.len()), (-2.0, 0));
// A handle already in line moves nowhere, yet is still "align".
let (q, ov) = t.snap_point_with(Point::new(400.0, 200.0), &[], 4.0);
assert_eq!(q, Point::new(400.0, 200.0));
assert!(matches!(&ov[..], [Overlay::Label { text, .. }] if text == "align"), "{ov:?}");
// Anchor/Path Labels off: on the anchor, without saying so.
let c = ToolContext { anchor_path_labels: false, ..cx(&d, &s, &p) };
let t = Targets::collect(&d, &[], None).styled(&c);
let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0);
assert_eq!((q, ov.len()), (Point::new(100.0, 100.0), 0));
let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0);
assert_eq!(q.y, 200.0);
assert!(ov.iter().all(|o| matches!(o, Overlay::Line { .. })) && !ov.is_empty(), "{ov:?}");
assert_eq!(t.snap_guide(false, 199.0, 4.0), (200.0, vec![]));
}
/// Dragging an anchor of a path: its other anchors and the segments not touching it stay
/// targets, but neither it nor the path's bounds (which move) are.
#[test]
+37 -2
View File
@@ -31,6 +31,7 @@ pub mod shape;
pub mod slice;
pub mod symbolism;
pub mod text;
pub mod typewidget;
pub mod xform;
use serde::{Deserialize, Serialize};
@@ -301,6 +302,20 @@ pub struct ToolContext<'a> {
/// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with
/// placeholder text, selected.
pub placeholder_text: bool,
/// Smart Guides → Color: the smart guides' lines and labels (RGB).
pub smart_guide_color: [u8; 3],
/// Smart Guides → Alignment Guides: the lines along the edges and centres the art lines up
/// with show. Off, the art still snaps into line.
pub alignment_guides: bool,
/// Smart Guides → Anchor/Path Labels: the "anchor", "center", "path"… labels show.
pub anchor_path_labels: bool,
/// Smart Guides → Measurement Labels: the size and offset readouts while drawing and moving.
pub measurement_labels: bool,
/// Smart Guides → Transform Tools: the readouts while scaling, rotating and shearing.
pub transform_tools_guides: bool,
/// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the
/// pointer, a dragged edge or a drawn point.
pub snapping_tolerance: f64,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<ScreenFrame>,
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
@@ -312,6 +327,10 @@ impl ToolContext<'_> {
pub fn tol(&self, px: f64) -> f64 {
px / self.zoom.max(1e-9)
}
/// How near (document units) a smart guide target pulls: Smart Guides → Snapping Tolerance.
pub fn snap_tol(&self) -> f64 {
self.tol(self.snapping_tolerance)
}
/// A length as measurement labels show it, in the General unit (`12.50 mm`).
pub fn len(&self, v: f64) -> String {
self.unit.readout(v)
@@ -381,7 +400,8 @@ pub enum Cursor {
PenDelete,
PenClose,
PenContinue,
/// Over the last anchor of the path being drawn: a click retracts its outgoing handle.
/// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with
/// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture).
PenConvert,
Text,
Hand,
@@ -412,6 +432,13 @@ pub enum Cursor {
BlendAnchor,
/// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket.
PathBracket,
/// Over the type widget of selected type (a double-click converts point type to area type and
/// back): the arrow with a type badge.
TypeWidget,
/// The Shape Builder: a crosshair with a plus (merge mode)...
ShapeBuilder,
/// ...or, with Alt held, a minus (erase mode).
ShapeBuilderErase,
}
impl Cursor {
@@ -429,7 +456,9 @@ impl Cursor {
| Cursor::Slice
| Cursor::Blend
| Cursor::BlendObject
| Cursor::BlendAnchor => Cursor::Crosshair,
| Cursor::BlendAnchor
| Cursor::ShapeBuilder
| Cursor::ShapeBuilderErase => Cursor::Crosshair,
c => c,
}
}
@@ -631,6 +660,12 @@ pub(crate) mod testutil {
pen_rubber_band: true,
curvature_rubber_band: true,
placeholder_text: false,
smart_guide_color: guides::MAGENTA,
alignment_guides: true,
anchor_path_labels: true,
measurement_labels: true,
transform_tools_guides: true,
snapping_tolerance: 4.0,
screen: None,
plane_widget: Some(Default::default()),
}
+114 -28
View File
@@ -5,6 +5,9 @@
//! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°.
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
//! Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as
//! the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes it a
//! corner and dragging an anchor pulls out new symmetric handles.
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
//! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
@@ -15,6 +18,7 @@ use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use crate::draw2::{AnchorTool, hit_handle};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
@@ -30,6 +34,8 @@ pub struct PenTool {
/// The last anchor of the path being drawn, while a click retracts its outgoing handle or a
/// drag pulls a new one out: (path, subpath, anchor, its position).
handle: Option<(NodeId, usize, usize, Point)>,
/// The Anchor Point tool, while an Alt gesture on a selected path's handle or anchor lasts.
convert: Option<AnchorTool>,
}
/// The open path the pen is extending: the single selected open path.
@@ -57,6 +63,21 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
Some((si, ai))
}
/// Alt held over a handle end or an anchor of a selected path: the Anchor Point tool's gesture.
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
let tol = cx.tol(4.0);
m.alt && (hit_handle(cx, p, tol).is_some() || crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some())
}
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
}
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
Action::Preview(
@@ -70,7 +91,7 @@ impl Tool for PenTool {
"pen"
}
fn busy(&self) -> bool {
self.drag.is_some() || self.handle.is_some()
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
@@ -103,10 +124,16 @@ impl Tool for PenTool {
self.handle = Some((id, si, ai, last));
return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)];
}
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
self.drag = Some((p, false));
self.in_h = p;
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
if let Some(acts) = spine_click(cx, p, tol) {
return acts;
}
@@ -128,6 +155,9 @@ impl Tool for PenTool {
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
}
PointerKind::Drag => {
if let Some(t) = &mut self.convert {
return t.pointer(cx, ev);
}
if let Some((id, si, ai, a)) = self.handle {
let mut h = ev.pos;
if ev.mods.shift {
@@ -173,6 +203,9 @@ impl Tool for PenTool {
)]
}
PointerKind::Up => {
if let Some(mut t) = self.convert.take() {
return t.pointer(cx, ev);
}
if self.handle.take().is_some() {
return vec![Action::Commit];
}
@@ -185,22 +218,26 @@ impl Tool for PenTool {
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Enter | ToolKey::Escape => {
self.drawing = false;
self.drag = None;
self.handle = None;
vec![]
// An Alt gesture under way ends as it stands.
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.drawing = false;
vec![]
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(t) = &self.convert {
return t.overlays(cx);
}
if !cx.pen_rubber_band || !self.drawing || self.drag.is_some() || self.handle.is_some() {
return vec![];
}
@@ -216,27 +253,32 @@ impl Tool for PenTool {
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0);
let active = active_path(cx);
if self.drawing
&& let Some((id, first, last, _)) = active
{
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if p.distance(first) <= tol && !one {
return Cursor::PenClose;
}
if p.distance(last) <= tol {
return Cursor::PenConvert;
}
}
if alt_converts(cx, p, m) {
return Cursor::PenConvert;
}
if !self.drawing {
match spine_click(cx, p, cx.tol(5.0)).as_deref() {
match spine_click(cx, p, tol).as_deref() {
Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete,
Some([_]) => return Cursor::PenAdd,
_ => {}
}
}
if let Some((id, first, last, _)) = active_path(cx) {
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if self.drawing && p.distance(first) <= cx.tol(5.0) && !one {
return Cursor::PenClose;
}
if self.drawing && p.distance(last) <= cx.tol(5.0) {
return Cursor::PenConvert;
}
if !self.drawing && (p.distance(last) <= cx.tol(5.0) || p.distance(first) <= cx.tol(5.0)) {
if active.is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
return Cursor::PenContinue;
}
}
if !self.drawing {
match auto_add_delete(cx, p, m, cx.tol(5.0)) {
match auto_add_delete(cx, p, m, tol) {
Some(Action::Exec(c, _)) if c == "path.removeAnchor" => return Cursor::PenDelete,
Some(_) => return Cursor::PenAdd,
None => {}
@@ -246,6 +288,19 @@ impl Tool for PenTool {
}
}
impl PenTool {
/// Hand an Alt press on a selected path's handle or anchor to the Anchor Point tool.
fn alt_convert(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
if !alt_converts(cx, ev.pos, ev.mods) {
return None;
}
let mut t = AnchorTool::new("anchorPoint");
let acts = t.pointer(cx, ev);
self.convert = Some(t);
Some(acts)
}
}
/// Auto Add/Delete: what a click at `p` does to a selected path. On one of its anchors it deletes
/// it; else, where one of its segments passes within `tol`, it adds an anchor. None when the
/// preference turns it off, Shift is held, or no selected path is there.
@@ -253,15 +308,8 @@ fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option<Acti
if !cx.auto_add_delete || m.shift {
return None;
}
let paths = || {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
};
let anchor = crate::draw2::anchor_in(cx, paths(), p, tol).map(crate::draw2::remove_anchor);
anchor.or_else(|| crate::draw2::segment_in(cx, paths(), p, tol).map(crate::draw2::insert_anchor))
let anchor = crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).map(crate::draw2::remove_anchor);
anchor.or_else(|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).map(crate::draw2::insert_anchor))
}
/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it
@@ -501,4 +549,42 @@ mod tests {
let a = click(&mut t, &cx, 325.0, 351.0, shift);
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
}
#[test]
fn alt_over_a_shown_handle_or_an_anchor_converts() {
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(300.0, 200.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 100.0), Point::new(250.0, 100.0));
let (d, id, mut s) = drawing(sp);
let p = paint();
let alt = Mods { alt: true, ..Mods::default() };
let t = PenTool::default();
let cx0 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen);
assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen);
// A drag of the handle moves it alone, as one gesture.
let mut t = PenTool::default();
assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 250.0, 101.0).with_mods(alt)), vec![]);
assert!(t.busy());
let a = t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0).with_mods(alt));
assert_eq!(
a,
vec![
Action::Begin("Reshape".into()),
Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 260.0, "y": 140.0, "independent": true})
)
]
);
// Alt let go mid-drag: the gesture goes on to its end.
assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]);
assert!(!t.busy());
// With other anchors direct-selected the handles of this one are hidden, and not grabbed.
s.anchors.insert(id, std::collections::BTreeSet::from([(0, 0)]));
let cx1 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen);
}
}
+250 -40
View File
@@ -1,9 +1,11 @@
//! Ruler guides on the canvas, for the selection tools. A press on a guide selects it (Shift adds
//! it to the selected guides or, released without a drag, takes it out); dragging moves the
//! selected guides (Alt copies them). The dragged guide snaps: with Shift to the ruler's ticks,
//! else as a drawn point does (whole pixels, the grid, or with Smart Guides the art's edges,
//! centres and anchors). Dropped off the canvas onto its ruler, the guide is deleted. Hidden or
//! locked guides (View › Guides) can't be picked.
//! Ruler guides on the canvas. Dragged out of a ruler with any tool ([`NewGuide`]), a guide is
//! made where it is released over the canvas: with the Artboard tool, an artboard guide of the
//! active artboard. For the selection tools ([`GuideEdit`]), a press on a guide selects it (Shift
//! adds it to the selected guides or, released without a drag, takes it out); dragging moves the
//! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides) can't be picked.
use serde_json::json;
use vectorcraft_geom::Point;
@@ -22,6 +24,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
.guides
.iter()
.enumerate()
.filter(|(_, g)| cx.doc.guide_passes(g, p, tol))
.map(|(i, g)| (i, off(g.pos, g.vertical)))
.filter(|(_, d)| *d <= tol)
.min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0)))
@@ -47,15 +50,122 @@ struct Drag {
deselect: bool,
}
/// Snapping for a dragged ruler guide, its targets gathered when the drag begins.
#[derive(Default)]
struct GuideSnap {
/// With Smart Guides the art's and the artboards' edges, centres and anchors, else with Snap
/// to Point the anchors, and how near (in screen pixels) they pull; none with pixels or the
/// grid snapping instead, or nothing on.
targets: Option<(Targets, f64)>,
/// The target the guide snapped to.
snapped: Vec<Overlay>,
}
impl GuideSnap {
fn new(cx: &ToolContext) -> Self {
let targets = || Targets::collect(cx.doc, &[], None);
let targets = if cx.snap_to_pixel || cx.snap_to_grid {
None
} else if cx.smart_guides {
Some((targets().styled(cx), cx.snapping_tolerance))
} else if cx.snap_to_point {
Some((targets().anchors_only(), cx.snap_tolerance))
} else {
None
};
Self { targets, snapped: vec![] }
}
/// Where a guide dragged to `v` (the x of a vertical one, the y of a horizontal one) goes:
/// with Shift on the nearest ruler tick, else on whole pixels, the grid or a target.
fn snap(&mut self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> f64 {
self.snapped.clear();
if shift {
return cx.unit.snap_to_ruler_tick(v, cx.zoom);
}
if cx.snap_to_pixel {
return v.round();
}
if cx.snap_to_grid {
return vectorcraft_geom::snap::snap_to_grid(v, cx.grid_step(), 0.0);
}
let Some((t, tol)) = &self.targets else { return v };
let (at, snapped) = t.snap_guide(vertical, v, cx.tol(*tol));
self.snapped = snapped;
at
}
/// The target the guide at `at` snapped to, and its position by the `pointer`.
fn overlays(&self, cx: &ToolContext, vertical: bool, at: f64, pointer: Point) -> Vec<Overlay> {
let mut o = self.snapped.clone();
if cx.measurement_labels {
let axis = if vertical { "X" } else { "Y" };
o.push(Overlay::Measure { p: pointer, text: format!("{axis}: {}", cx.len(at)) });
}
o
}
}
/// A guide dragged out of a ruler (a vertical one out of the left ruler), with any tool: it shows
/// where the pointer is, snapped as a moved guide is, and is made where the button is released
/// over the canvas (`guide.add`, previewed as it goes); taken off the canvas, it goes again.
pub struct NewGuide {
vertical: bool,
/// The artboard (index) it belongs to: an artboard guide.
artboard: Option<usize>,
snap: GuideSnap,
/// Where it shows (none off the canvas), and the pointer.
at: Option<f64>,
pointer: Point,
}
impl NewGuide {
/// A vertical guide out of the left ruler, else a horizontal one out of the top ruler; an
/// artboard guide of `artboard`, if any.
pub fn new(cx: &ToolContext, vertical: bool, artboard: Option<usize>) -> Self {
Self { vertical, artboard, snap: GuideSnap::new(cx), at: None, pointer: Point::ZERO }
}
/// The pointer at `ev`, over the canvas or not (`on_canvas`): the actions that show the guide
/// there or take it away, and on release make it.
pub fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent, on_canvas: bool) -> Vec<Action> {
let mut out = vec![];
self.pointer = ev.pos;
if on_canvas {
let at = self.snap.snap(cx, self.vertical, if self.vertical { ev.pos.x } else { ev.pos.y }, ev.mods.shift);
if self.at.is_none() {
out.push(Action::Begin("New Guide".into()));
}
if self.at != Some(at) {
let mut p = json!({ "vertical": self.vertical, "pos": at });
if let Some(ab) = self.artboard {
p["artboard"] = json!(ab);
}
out.push(Action::Preview("guide.add".into(), p));
}
self.at = Some(at);
} else if self.at.take().is_some() {
self.snap.snapped.clear();
out.push(Action::Cancel);
}
if ev.kind == PointerKind::Up && self.at.take().is_some() {
out.push(Action::Commit);
}
out
}
/// While the guide shows: the target it snapped to and its position by the pointer.
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
self.at.map(|at| self.snap.overlays(cx, self.vertical, at, self.pointer)).unwrap_or_default()
}
}
/// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for
/// the rest of the gesture.
#[derive(Default)]
pub struct GuideEdit {
drag: Option<Drag>,
/// Smart Guides targets, gathered when the drag starts.
targets: Option<Targets>,
/// The smart guide the dragged guide snapped to.
snapped: Vec<Overlay>,
snap: GuideSnap,
}
impl GuideEdit {
@@ -80,8 +190,7 @@ impl GuideEdit {
began: false,
deselect: shift && selected,
});
self.targets = None;
self.snapped.clear();
self.snap = GuideSnap::default();
Some(match (selected, shift) {
(true, _) => vec![],
(false, true) => vec![Action::Exec("guide.select".into(), json!({ "indexes": [index], "toggle": true }))],
@@ -100,8 +209,7 @@ impl GuideEdit {
}
PointerKind::Up => {
self.drag = None;
self.targets = None;
self.snapped.clear();
self.snap = GuideSnap::default();
if d.began && on_ruler(cx, d.vertical, ev.pos) {
// Dropped on its ruler: the guide goes (a copy is just not made).
let mut out = vec![Action::Cancel];
@@ -134,35 +242,17 @@ impl GuideEdit {
}
d.began = true;
out.push(Action::Begin(if ev.mods.alt { "Copy Guide" } else { "Move Guide" }.into()));
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &[], None));
self.snap = GuideSnap::new(cx);
}
let moved = p - d.start;
let (at, snapped) = self.snap(cx, d.vertical, d.from + if d.vertical { moved.x } else { moved.y }, ev.mods.shift);
(d.at, d.pointer, self.snapped) = (at, p, snapped);
let at = self.snap.snap(cx, d.vertical, d.from + if d.vertical { moved.x } else { moved.y }, ev.mods.shift);
(d.at, d.pointer) = (at, p);
// The other selected guides follow the pointer: vertical ones across, horizontal ones down.
let (dx, dy) = if d.vertical { (at - d.from, moved.y) } else { (moved.x, at - d.from) };
out.push(Action::Preview("guide.move".into(), json!({ "dx": dx, "dy": dy, "copy": ev.mods.alt })));
out
}
/// Where a guide dragged to `v` goes: with Shift on the nearest ruler tick, else on whole
/// pixels, the grid or a smart guide (as a drawn point snaps).
fn snap(&self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> (f64, Vec<Overlay>) {
if shift {
return (cx.unit.snap_to_ruler_tick(v, cx.zoom), vec![]);
}
if cx.snap_to_pixel {
return (v.round(), vec![]);
}
if cx.snap_to_grid {
return (vectorcraft_geom::snap::snap_to_grid(v, cx.grid_step(), 0.0), vec![]);
}
match &self.targets {
Some(t) => t.snap_guide(vertical, v, cx.tol(5.0)),
None => (v, vec![]),
}
}
/// The pointer over a guide, or while one is dragged.
pub fn cursor(&self, cx: &ToolContext, p: Point) -> Option<Cursor> {
if let Some(d) = &self.drag {
@@ -174,10 +264,7 @@ impl GuideEdit {
/// While a guide is dragged: the smart guide it snapped to and its position by the pointer.
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(d) = self.drag.filter(|d| d.began) else { return vec![] };
let mut o = self.snapped.clone();
let axis = if d.vertical { "X" } else { "Y" };
o.push(Overlay::Measure { p: d.pointer, text: format!("{axis}: {}", cx.len(d.at)) });
o
self.snap.overlays(cx, d.vertical, d.at, d.pointer)
}
}
@@ -201,7 +288,7 @@ mod tests {
fn guides_doc() -> vectorcraft_doc::Document {
let (mut d, _) = doc_with_rect();
d.guides = vec![Guide { vertical: true, pos: 300.0 }, Guide { vertical: false, pos: 400.0 }];
d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)];
d
}
@@ -266,6 +353,129 @@ mod tests {
assert_eq!(drag_to(&grid, 226.0, Mods::default()).0, 225.0, "the grid's 9 pt subdivisions");
}
/// #451: a guide dragged out of a ruler lands on the art's corners, side midpoints (its
/// centre's line) and anchors, and on the artboard's edges and centre, labelled.
#[test]
fn a_new_guide_snaps_to_the_art_and_the_artboard() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let m = Mods::default();
let drag = |vertical: bool, v: f64| {
let mut g = NewGuide::new(&c, vertical, None);
let at = if vertical { Point::new(v, 50.0) } else { Point::new(50.0, v) };
let a = g.pointer(&c, &ev(PointerKind::Drag, at.x, at.y, m), true);
let Some(Action::Preview(cmd, p)) = a.last() else { panic!("{a:?}") };
assert_eq!((a[0].clone(), cmd.as_str(), &p["vertical"]), (Action::Begin("New Guide".into()), "guide.add", &json!(vertical)));
let label = g.overlays(&c).iter().find_map(|o| match o {
Overlay::Label { text, .. } => Some(text.clone()),
_ => None,
});
(p["pos"].as_f64().unwrap(), label)
};
assert_eq!(drag(true, 152.0), (150.0, Some("center".into())), "the line through the top and bottom midpoints");
assert_eq!(drag(false, 103.0), (100.0, Some("path".into())), "the top edge, through two corners");
assert_eq!(drag(true, 497.0), (500.0, Some("artboard".into())), "the artboard's right edge");
assert_eq!(drag(false, 248.0), (250.0, Some("center".into())), "the artboard's centre");
assert_eq!(drag(true, 320.0), (320.0, None), "nothing within reach");
}
/// Preferences › Smart Guides (#394) reach a guide dragged out of a ruler: Snapping
/// Tolerance sets how far a target pulls it, Anchor/Path Labels and Measurement Labels what
/// shows by it.
#[test]
fn a_new_guide_follows_the_smart_guide_preferences() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let drag = |c: &ToolContext| {
let mut g = NewGuide::new(c, true, None);
let a = g.pointer(c, &ev(PointerKind::Drag, 497.0, 50.0, Mods::default()), true);
let Some(Action::Preview(_, p)) = a.last() else { panic!("{a:?}") };
(p["pos"].as_f64().unwrap(), g.overlays(c))
};
let (at, ov) = drag(&ToolContext { snapping_tolerance: 2.0, ..cx(&d, &s, &p) });
assert_eq!((at, ov.len()), (497.0, 1), "3 px off is beyond 2: only the readout {ov:?}");
let (at, ov) = drag(&ToolContext { anchor_path_labels: false, measurement_labels: false, ..cx(&d, &s, &p) });
assert_eq!((at, ov), (500.0, vec![]), "onto the artboard's edge, nothing said");
}
#[test]
fn a_new_guide_is_made_on_release_over_the_canvas_only() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) };
let m = Mods::default();
let mut g = NewGuide::new(&c, false, Some(0));
let preview = |pos: f64| Action::Preview("guide.add".into(), json!({"vertical": false, "pos": pos, "artboard": 0}));
// Off the canvas (still over the ruler): nothing yet.
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![]);
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 30.0, m), true), vec![Action::Begin("New Guide".into()), preview(30.0)]);
assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "Y: 30.00 pt"));
// The same place again: no new preview.
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 90.0, 30.0, m), true), vec![]);
// Back onto the ruler it goes, and comes back over the canvas.
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![Action::Cancel]);
assert!(g.overlays(&c).is_empty());
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 60.0, m), true), vec![Action::Begin("New Guide".into()), preview(60.0)]);
// Shift: the ruler's ticks.
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 63.0, Mods { shift: true, ..m }), true), vec![preview(65.0)]);
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 40.0, 65.0, m), true), vec![Action::Commit]);
// Released off the canvas: none.
let mut g = NewGuide::new(&c, true, None);
g.pointer(&c, &ev(PointerKind::Drag, 30.0, 40.0, m), true);
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, -5.0, 40.0, m), false), vec![Action::Cancel]);
}
/// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its
/// distance), not the edges' midpoints.
#[test]
fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() {
let d = guides_doc();
let mut s = Selection::default();
s.set_guides([0]);
let p = paint();
let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) };
let moved_to = |c: &ToolContext, x: f64| {
let mut g = GuideEdit::default();
g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default()));
let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, Mods::default())).unwrap();
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
300.0 + v["dx"].as_f64().unwrap()
};
assert_eq!(moved_to(&off, 201.5), 200.0);
assert_eq!(moved_to(&off, 151.0), 151.0, "the centre is no anchor");
assert_eq!(moved_to(&off, 203.0), 203.0, "beyond the 2 px Snap to Point distance");
let neither = ToolContext { snap_to_point: false, ..off };
assert_eq!(moved_to(&neither, 201.5), 201.5);
let mut g = NewGuide::new(&off, true, None);
let a = g.pointer(&off, &ev(PointerKind::Drag, 98.5, 20.0, Mods::default()), true);
assert_eq!(a.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": 100.0}))));
}
/// #451: an artboard guide runs across its artboard only: it is picked there, and pulls a
/// point with Snap to Point there.
#[test]
fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() {
let (mut d, _) = doc_with_rect();
let id = d.next_artboard_id();
d.artboards.push(vectorcraft_doc::Artboard {
id,
name: "B".into(),
rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0),
show_center_mark: false,
show_cross_hairs: false,
});
d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }];
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0));
assert_eq!(guide_at(&c, Point::new(602.0 - 4.0, 151.0)), Some(0), "within the tolerance of its end");
assert_eq!(guide_at(&c, Point::new(300.0, 151.0)), None, "not across the first artboard");
let t = Targets::snap_to_point(&ToolContext { smart_guides: false, ..c }, &[]).unwrap();
assert_eq!(t.snap_point(Point::new(700.0, 151.5), 2.0).0, Point::new(700.0, 150.0));
assert_eq!(t.snap_point(Point::new(300.0, 151.5), 2.0).0, Point::new(300.0, 151.5));
}
#[test]
fn shift_click_toggles_and_alt_drag_copies() {
let d = guides_doc();
+68 -10
View File
@@ -5,7 +5,8 @@
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
//! ([`crate::pathtype`]).
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
//! ([`crate::typewidget`]).
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
//! scaling its type: the text reflows at its size.
@@ -20,6 +21,7 @@ use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::Targets;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
use crate::typewidget;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
#[derive(Clone, Debug, Default)]
@@ -80,7 +82,7 @@ impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
Self {
bounds: selection_bounds(cx),
targets: cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None)),
targets: cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx)),
points: Targets::snap_to_point(cx, &cx.selection.objects),
}
}
@@ -94,7 +96,7 @@ impl MoveSnap {
guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
let (adj, ov) = t.snap_rect(b + d, cx.snap_tol());
d += adj;
guides = ov;
}
@@ -165,7 +167,7 @@ impl Tool for SelectionTool {
match (ev.kind, self.state.clone()) {
(PointerKind::DoubleClick, _) => {
self.state = State::Idle;
if let Some(a) = corners::double_click(cx, p) {
if let Some(a) = corners::double_click(cx, p).or_else(|| typewidget::double_click(cx, p)) {
return vec![a];
}
if crate::rulerguide::guide_at(cx, p).is_some() {
@@ -195,6 +197,11 @@ impl Tool for SelectionTool {
self.state = State::Bracket(b);
return vec![];
}
// The type widget answers a double-click only: its clicks neither drag nor deselect.
if typewidget::over_widget(cx, p) {
self.state = State::Idle;
return vec![];
}
if cx.show_bbox
&& let Some(bx) = selection_box(cx)
{
@@ -205,7 +212,7 @@ impl Tool for SelectionTool {
// Only area type: the step resizes type areas; anything else scales.
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
// Smart Guides align what the handle moves with the other objects.
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None));
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
self.state = State::Scaling { handle, bx, areas };
return vec![Action::Begin(label.into())];
}
@@ -268,7 +275,7 @@ impl Tool for SelectionTool {
(d, self.guides) = snap.snap(cx, start, d);
}
self.state = State::Moving { start, began: true, deselect: None };
self.measure = Some((p, cx.offset_label(d.x, d.y)));
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
out
}
@@ -277,10 +284,10 @@ impl Tool for SelectionTool {
let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
self.guides.clear();
if let Some(t) = &self.targets {
(a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.tol(5.0));
(a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.snap_tol());
}
let nr = a.transform_rect_bbox(bx.rect);
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false });
if areas {
params["typeAreas"] = json!(true);
@@ -289,7 +296,7 @@ impl Tool for SelectionTool {
}
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
self.measure = Some((p, format!("{:.1}°", -deg)));
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
}
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
@@ -382,6 +389,9 @@ impl Tool for SelectionTool {
if over_bracket(cx, p) {
return Cursor::PathBracket;
}
if typewidget::over_widget(cx, p) {
return Cursor::TypeWidget;
}
if cx.show_bbox
&& let Some(bx) = selection_box(cx)
{
@@ -457,7 +467,7 @@ mod tests {
#[test]
fn a_press_on_a_ruler_guide_picks_it_over_the_art() {
let (mut d, _) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide { vertical: false, pos: 150.0 });
d.guides.push(vectorcraft_doc::Guide::new(false, 150.0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
@@ -592,6 +602,54 @@ mod tests {
assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}");
}
/// Smart Guides › Measurement Labels, Transform Tools and Snapping Tolerance (#394): the
/// readouts while moving and while scaling each follow their option; the tolerance sets how
/// far a dragged handle is pulled.
#[test]
fn smart_guide_display_preferences_drive_the_readouts_and_the_pull() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 100.0, 300.0, 150.0)), Appearance::default_art()))
.unwrap();
let mut s = Selection::default();
s.add(id);
let p = paint();
let measures = |t: &SelectionTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count();
let height = |a: &[Action]| {
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
v["matrix"][3].as_f64().unwrap() * 50.0
};
// Moving: the offset readout is a measurement label.
for on in [true, false] {
let c = ToolContext { measurement_labels: on, ..cx(&d, &s, &p) };
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 275.0, 125.0));
t.pointer(&c, &ev(PointerKind::Drag, 285.0, 135.0));
assert_eq!(measures(&t, &c), usize::from(on), "measurement labels {on}");
t.pointer(&c, &ev(PointerKind::Up, 285.0, 135.0));
}
// Scaling by a handle: the size readout belongs to Transform Tools.
for on in [true, false] {
let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) };
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
t.pointer(&c, &ev(PointerKind::Drag, 275.0, 180.0));
assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}");
t.pointer(&c, &ev(PointerKind::Up, 275.0, 180.0));
}
// Snapping Tolerance: 6 px from the neighbour's bottom edge is beyond 4, within 8.
for (tol, want) in [(4.0, 94.0), (8.0, 100.0), (0.0, 94.0)] {
let c = ToolContext { snapping_tolerance: tol, ..cx(&d, &s, &p) };
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 194.0));
assert!((height(&a) - want).abs() < 1e-9, "tolerance {tol}: {a:?}");
t.pointer(&c, &ev(PointerKind::Up, 275.0, 194.0));
}
}
#[test]
fn handle_drag_of_a_turned_box_snaps_on_the_page() {
use vectorcraft_doc::{Appearance, Node};
+3 -1
View File
@@ -206,7 +206,9 @@ impl Tool for ShapeTool {
Some(s) if self.began => {
let d = self.last - s;
let mut o = self.guides.clone();
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
if cx.measurement_labels {
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
}
o
}
_ => vec![],
+7 -4
View File
@@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
cx.doc
.guides
.iter()
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol)
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_passes(g, p, tol))
.min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs()))
};
if let Some(g) = nearest(true, p.x) {
@@ -116,7 +116,9 @@ impl Tool for SliceTool {
Some(s) if self.began => {
let d = self.last - s;
let mut o = self.guides.clone();
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
if cx.measurement_labels {
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
}
o
}
_ => vec![],
@@ -280,7 +282,8 @@ impl Tool for SliceSelectionTool {
o.extend(Handle::ALL.iter().map(|h| Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 }));
}
}
match &self.drag {
// Smart Guides › Measurement Labels: the size readouts while resizing or moving slices.
match self.drag.as_ref().filter(|_| cx.measurement_labels) {
Some(Drag::Resize { id, began: true, .. }) => {
if let Some(r) = cx.doc.slice_bounds(*id) {
o.push(Overlay::Measure {
@@ -341,7 +344,7 @@ mod tests {
#[test]
fn the_slice_tool_drags_out_a_slice_in_one_undo_step() {
let (mut d, _) = doc_with_rect();
d.guides.push(Guide { vertical: true, pos: 403.0 });
d.guides.push(Guide::new(true, 403.0));
let s = Selection::default();
let p = paint();
let mut c = cx(&d, &s, &p);
+135
View File
@@ -0,0 +1,135 @@
//! The type widget: a small circle beside the right side of a selected type object's bounding
//! box, hollow on point type and filled on area type. Double-clicking it converts the type to the
//! other kind (Type › Convert To Area Type / Convert To Point Type), keeping its text and styles.
//! The Selection tool hits it and the canvas draws it from the same geometry.
use serde_json::json;
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, Selection, TextKind};
use vectorcraft_geom::Point;
use crate::select::selection_box;
use crate::{Action, ToolContext};
/// The widget's centre sits this far (screen px) outside the middle of the box's right side, clear
/// of its handle.
const OFFSET_PX: f64 = 16.0;
/// The widget's radius (screen px), as the canvas draws it.
pub const RADIUS_PX: f32 = 3.5;
/// How near (screen px) the pointer must be to hit it.
const HIT_PX: f64 = 5.0;
/// The type widget of a selection that is exactly one editable point or area type object.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TypeWidget {
pub id: NodeId,
/// Centre in document coordinates.
pub at: Point,
/// Area type (a filled circle; point type's is hollow).
pub area: bool,
}
impl TypeWidget {
/// The widget beside `bx`, the selection's bounding box, at `zoom` (screen px per document
/// point). None unless the selection is one editable point or area type object (not type on a
/// path, nor type in perspective, which transforms whole).
pub fn of(doc: &Document, selection: &Selection, bx: &OrientedBox, zoom: f64) -> Option<Self> {
let [id] = selection.objects[..] else { return None };
if !doc.is_editable(id) || !zoom.is_finite() || zoom <= 0.0 {
return None;
}
let n = doc.node(id).filter(|n| n.perspective.is_none())?;
let NodeKind::Text(t) = &n.kind else { return None };
let area = match t.kind {
TextKind::Point => false,
TextKind::Area { .. } => true,
_ => return None,
};
let r = bx.rect;
Some(Self { id, at: bx.to_doc() * Point::new(r.x1 + OFFSET_PX / zoom, r.center().y), area })
}
/// The widget the Selection tool can double-click (its bounding box showing).
pub fn for_tool(cx: &ToolContext) -> Option<Self> {
if !cx.show_bbox {
return None;
}
Self::of(cx.doc, cx.selection, &selection_box(cx)?, cx.zoom)
}
/// The widget of `cx` if it is under `p`.
fn at(cx: &ToolContext, p: Point) -> Option<Self> {
Self::for_tool(cx).filter(|w| w.at.distance(p) <= cx.tol(HIT_PX))
}
/// The command converting the type to the other kind.
pub fn command(&self) -> &'static str {
if self.area { "type.convertToPointType" } else { "type.convertToAreaType" }
}
}
/// Is `p` over the type widget?
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
TypeWidget::at(cx, p).is_some()
}
/// A double-click on the type widget converts point type to area type and back.
pub fn double_click(cx: &ToolContext, p: Point) -> Option<Action> {
let w = TypeWidget::at(cx, p)?;
Some(Action::Exec(w.command().into(), json!({ "ids": [w.id.0] })))
}
#[cfg(test)]
mod tests {
use vectorcraft_doc::{Node, TextObject};
use super::*;
use crate::select::SelectionTool;
use crate::testutil::*;
use crate::{Cursor, Mods, PointerEvent, PointerKind, Tool};
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
PointerEvent::new(kind, p.x, p.y)
}
#[test]
fn the_widget_sits_right_of_the_box_and_a_double_click_converts() {
let (mut d, area) = doc_with_area_type();
let point = d.alloc_id();
let l = d.layers[0].id;
let t = TextObject::point(Point::new(100.0, 300.0), "Point", Default::default());
d.insert(Some(l), 0, Node::new(point, NodeKind::Text(Box::new(t)))).unwrap();
let p = paint();
for (id, cmd) in [(area, "type.convertToPointType"), (point, "type.convertToAreaType")] {
let mut s = Selection::default();
s.add(id);
let c = cx(&d, &s, &p);
let bx = selection_box(&c).unwrap();
let w = TypeWidget::for_tool(&c).unwrap();
assert_eq!(w.area, id == area);
assert!((w.at.x - (bx.rect.x1 + 16.0)).abs() < 1e-9 && (w.at.y - bx.rect.center().y).abs() < 1e-9, "{w:?} {bx:?}");
// The presses and releases of a double-click change nothing (no marquee deselects).
let mut t = SelectionTool::default();
for k in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up] {
assert_eq!(t.pointer(&c, &ev(k, w.at)), vec![], "{k:?}");
}
let a = t.pointer(&c, &ev(PointerKind::DoubleClick, w.at + vectorcraft_geom::Vec2::new(2.0, 0.0)));
assert_eq!(a, vec![Action::Exec(cmd.into(), json!({ "ids": [id.0] }))]);
assert_eq!(t.cursor(&c, w.at, Mods::default()), Cursor::TypeWidget);
// A double-click on the type itself still edits it.
let inside = bx.center();
assert_eq!(t.pointer(&c, &ev(PointerKind::DoubleClick, inside)), vec![Action::SwitchTool("type".into())]);
// Hidden bounding box: no widget.
let c = ToolContext { show_bbox: false, ..cx(&d, &s, &p) };
assert!(TypeWidget::for_tool(&c).is_none());
}
// Two objects, or type on a path: no widget.
let mut s = Selection::default();
s.add(point);
s.add(area);
assert!(TypeWidget::for_tool(&cx(&d, &s, &p)).is_none());
let (pd, path_type) = doc_with_path_type();
let mut s = Selection::default();
s.add(path_type);
assert!(TypeWidget::for_tool(&cx(&pd, &s, &p)).is_none());
}
}
+11 -8
View File
@@ -4,7 +4,8 @@
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
//! leaves the artboard where it was), drag a handle resizes it
//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes
//! the active one and Escape returns to the Selection tool. Moving and resizing snap like drawing
//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`) and Escape returns to
//! the Selection tool. Moving and resizing snap like drawing
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
//! snap too.
@@ -82,10 +83,10 @@ impl ArtboardTool {
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect, cx.move_locked_with_artboard),
_ => vec![],
};
Targets::for_artboard(cx.doc, index, &art)
Targets::for_artboard(cx.doc, index, &art).styled(cx)
});
// Nothing to skip: no artboard has this index.
self.copy_targets = cx.smart_guides.then(|| Targets::for_artboard(cx.doc, usize::MAX, &[]));
self.copy_targets = cx.smart_guides.then(|| Targets::for_artboard(cx.doc, usize::MAX, &[]).styled(cx));
}
/// Snap a dragged handle: to whole pixels, the grid, or Smart Guides (in that order, as when
@@ -100,7 +101,7 @@ impl ArtboardTool {
}
let Some(t) = &self.targets else { return p };
let offsets: &[Vec2] = if bleed == Vec2::ZERO { &[] } else { &[bleed] };
let (q, ov) = t.snap_point_with(p, offsets, cx.tol(5.0));
let (q, ov) = t.snap_point_with(p, offsets, cx.snap_tol());
self.guides = ov;
q
}
@@ -119,9 +120,9 @@ impl ArtboardTool {
let Some(t) = (if copy { &self.copy_targets } else { &self.targets }) else { return d };
let moved = rect + d;
let (adj, ov) = if cx.doc.setup.has_bleed() {
t.snap_rects(&[moved, cx.doc.setup.bleed_rect(moved)], cx.tol(5.0))
t.snap_rects(&[moved, cx.doc.setup.bleed_rect(moved)], cx.snap_tol())
} else {
t.snap_rect(moved, cx.tol(5.0))
t.snap_rect(moved, cx.snap_tol())
};
self.guides = ov;
d + adj
@@ -290,7 +291,9 @@ impl Tool for ArtboardTool {
if let Some(Drag::Create { start, cur }) = self.drag {
let r = Rect::from_points(start, cur);
o.push(Overlay::Marquee(r));
o.push(Overlay::Measure { p: cur + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.size_label(r.width(), r.height()) });
if cx.measurement_labels {
o.push(Overlay::Measure { p: cur + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.size_label(r.width(), r.height()) });
}
return o;
}
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
@@ -300,7 +303,7 @@ impl Tool for ArtboardTool {
o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 });
}
o.push(Overlay::Label { p: Point::new(r.x0, r.y0 - cx.tol(14.0)), text: format!("{:02} - {}", self.active + 1, ab.name), color: BLUE });
if let Some(Drag::Resize { began: true, .. }) = self.drag {
if let (Some(Drag::Resize { began: true, .. }), true) = (self.drag, cx.measurement_labels) {
o.push(Overlay::Measure {
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
text: cx.size_label(r.width(), r.height()),
+6 -6
View File
@@ -192,22 +192,22 @@ impl FreeTransformTool {
}
Op::Move => {
let v = move_delta(d.start, p, shift);
self.measure = Some((p, cx.offset_label(v.x, v.y)));
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(v.x, v.y)));
Affine::translate(v)
}
Op::Scale(h) => {
let mut a = scale_for_drag(d.rect, h, p, shift, m.alt);
if let Some(t) = &self.targets {
(a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.tol(5.0));
(a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.snap_tol());
}
let nr = a.transform_rect_bbox(d.rect);
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
a
}
Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt),
Op::Rotate => {
let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift);
self.measure = Some((p, format!("{:.1}°", -deg)));
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
a
}
};
@@ -232,8 +232,8 @@ impl Tool for FreeTransformTool {
self.drag = None;
let Some(r) = selection_bounds(cx) else { return vec![] };
if let Some(op) = self.classify(cx, r, p, ev.mods) {
self.targets =
(cx.smart_guides && op.handle().is_some()).then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
self.targets = (cx.smart_guides && op.handle().is_some())
.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
self.drag = Some(Drag { op, rect: r, start: p, began: false });
}
vec![]
+1 -1
View File
@@ -248,7 +248,7 @@ impl Tool for TransformTool {
{
o.push(Overlay::Line { a: d.origin, b: *p, color: super::CYAN, dashed: true });
}
if let Some((p, t)) = &self.measure {
if let (Some((p, t)), true) = (&self.measure, cx.transform_tools_guides) {
o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() });
}
o
+214 -55
View File
@@ -174,6 +174,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
ruler_guides(app, ui, full, rect, &xf);
}
panel_drop(app, ui, &resp, &xf);
context_menu(app, &resp, &xf);
let painter = ui.painter_at(rect);
@@ -319,12 +322,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let picked = LineLook { color: t.selection, ..look };
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
for (i, g) in doc.guides.iter().enumerate() {
let (a, b) = if g.vertical {
let x = xf.to_screen(Point::new(g.pos, 0.0)).x;
(pos2(x, rect.top()), pos2(x, rect.bottom()))
} else {
let y = xf.to_screen(Point::new(0.0, g.pos)).y;
(pos2(rect.left(), y), pos2(rect.right(), y))
let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) });
// An artboard guide runs across its artboard, a canvas guide across the window.
let (a, b) = match doc.guide_span(g) {
Some((from, to)) => (at(from), at(to)),
None if g.vertical => {
let x = at(0.0).x;
(pos2(x, rect.top()), pos2(x, rect.bottom()))
}
None => {
let y = at(0.0).y;
(pos2(rect.left(), y), pos2(rect.right(), y))
}
};
if selected.contains(&i) { picked } else { look }.guide(&painter, a, b);
}
@@ -359,7 +368,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.ui.view.rulers && app.ui.screen_mode < 3 {
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
ruler_guides(app, ui, full, rect, &xf, &t);
}
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
task_bar(app, ui, &xf);
@@ -388,6 +396,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
match ui.input(|i| i.pointer.hover_pos()) {
// An OS cursor: the system moves it at once, where a painted one trails the pointer
// (#444). The system cursor stands in for cursors without a glyph.
Some(_) if custom && crate::cursors::OS_CURSORS => {
let ppp = ui.ctx().native_pixels_per_point().unwrap_or_else(|| ui.ctx().pixels_per_point());
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
cursor_icon(c)
}
Some(hp) if custom && crate::cursors::paint(&painter, c, hp) => egui::CursorIcon::None,
_ => cursor_icon(c),
}
@@ -401,7 +416,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
use egui::CursorIcon as C;
match c {
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket => C::Default,
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket | Cursor::TypeWidget => C::Default,
Cursor::Move => C::Move,
Cursor::Crosshair => C::Crosshair,
Cursor::ResizeH => C::ResizeHorizontal,
@@ -423,21 +438,35 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::SliceSelect => C::Default,
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
Cursor::WidthPoint => C::Move,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
}
}
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
let alt_id = egui::Id::new("canvas-alt-wheel");
let alt_before = ui.data(|d| d.get_temp(alt_id)).unwrap_or(false);
let mut alt_turn = alt_before;
let (pointer, m, space, (factor, scroll)) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height())));
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut alt_turn)));
if alt_turn != alt_before {
ui.data_mut(|d| d.insert_temp(alt_id, alt_turn));
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
app.hover_doc = hover.map(|p| xf.to_doc(p));
let view = app.view_info();
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
// without moving the mouse (Alt switches the Shape Builder to erase mode).
let mods_changed = ui.data_mut(|d| {
let id = egui::Id::new("canvas-mods");
let prev = d.get_temp::<egui::Modifiers>(id);
d.insert_temp(id, m);
prev.is_some_and(|prev| prev != m)
});
// Zoom around the pointer, or scroll ([`wheel`]).
if resp.hovered() {
@@ -576,7 +605,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
}
} else if let Some(p) = hover
&& pointer.is_moving()
&& (pointer.is_moving() || mods_changed)
{
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
@@ -595,7 +624,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
}
}
if drag.is_some() || pointer.is_moving() {
if drag.is_some() || pointer.is_moving() || mods_changed {
ui.ctx().request_repaint();
}
}
@@ -604,14 +633,24 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
const WHEEL_ZOOM: f64 = 0.01;
/// What the wheel and a pinch did over the canvas this frame: a zoom factor (about the pointer)
/// and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel zoom.
/// With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel zooms, Shift-wheel scrolls up
/// and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points per wheel line and page.
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32) -> (f64, egui::Vec2) {
/// and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
/// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the
/// caller across frames).
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) {
if !wheel_zooms {
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a smoothed scroll.
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
*alt_turn = m.alt && !m.command;
}
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
return if i.modifiers.alt && scroll.y != 0.0 { (zoom * (f64::from(scroll.y) * WHEEL_ZOOM).exp(), egui::Vec2::ZERO) } else { (zoom, scroll) };
return if *alt_turn && scroll != egui::Vec2::ZERO {
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), egui::Vec2::ZERO)
} else {
(zoom, scroll)
};
}
// The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom.
let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta));
@@ -819,34 +858,30 @@ fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
[top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))]
}
/// A drag from a ruler onto the canvas makes a guide where the button is released: a horizontal
/// one from the top ruler, a vertical one from the left ruler; with Shift on the nearest ruler
/// tick. Released anywhere else, it makes none.
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf, t: &Tokens) {
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
/// it makes none.
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) {
let [top, left, _] = ruler_rects(full);
let shift = ui.input(|i| i.modifiers.shift);
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
let resp = ui.interact(r, egui::Id::new(id), Sense::drag());
let Some(p) = resp.interact_pointer_pos().filter(|p| canvas.contains(*p)) else { continue };
let d = xf.to_doc(p);
let mut pos = if vertical { d.x } else { d.y };
if shift {
pos = app.session.general_unit().snap_to_ruler_tick(pos, xf.zoom);
}
if resp.drag_stopped() {
let kind = if resp.drag_stopped() {
PointerKind::Up
} else if resp.dragged() {
PointerKind::Drag
} else {
continue;
};
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()));
let on_canvas = p.is_some_and(|p| canvas.contains(p));
if on_canvas {
// The new guide shows even if guides were hidden.
app.ui.view.guides = true;
if let Err(e) = app.run("guide.add", json!({ "vertical": vertical, "pos": pos })) {
app.status(e);
}
} else if resp.dragged() {
let at = xf.to_screen(if vertical { Point::new(pos, d.y) } else { Point::new(d.x, pos) });
let (a, b) = if vertical {
(pos2(at.x, canvas.top()), pos2(at.x, canvas.bottom()))
} else {
(pos2(canvas.left(), at.y), pos2(canvas.right(), at.y))
};
LineLook::guides(&app.session.prefs, t).guide(&ui.painter_at(canvas), a, b);
}
let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 };
if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) {
app.status(e.to_string());
}
}
}
@@ -1099,6 +1134,10 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return };
// Smart Guides › Object Highlighting: a Smart Guides display option, so it needs them on.
if !app.ui.view.smart_guides || !app.session.prefs.object_highlighting {
return;
}
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return;
}
@@ -1400,6 +1439,12 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
p.rect_filled(hr, 0.0, Color32::WHITE);
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
// The type widget beside it: hollow on point type, filled on area type.
if let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) {
let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::typewidget::RADIUS_PX);
p.circle_filled(c, r, if w.area { color } else { Color32::WHITE });
p.circle_stroke(c, r, Stroke::new(1.0, color));
}
}
}
@@ -1567,8 +1612,11 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
if !st.selection.anchors.is_empty() {
items.push((tl!("Remove Anchor Points"), "dc-pen-delete", "path.removeAnchors"));
// Direct-selected anchors take the place of a path's Offset Path and Simplify.
let anchors = !st.selection.anchors.is_empty();
if anchors {
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors"));
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors"));
}
if n > 1 {
items.push((tl!("Group"), "group", "object.group"));
@@ -1578,7 +1626,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
items.push((tl!("Isolate"), "square-dashed", "object.isolate"));
} else if is_text {
items.push((tl!("Create Outlines"), "type", "type.createOutlines"));
} else {
} else if !anchors {
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath"));
items.push((tl!("Simplify"), "spline", "object.path.simplify"));
}
@@ -1735,6 +1783,54 @@ mod tests {
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing");
}
/// #451: a guide dragged out of a ruler lands on the art's side midpoints (halving an
/// artboard-sized rectangle) and on the artboard's edges; an artboard guide is drawn across
/// its artboard only.
#[test]
fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap();
app.session.execute("select.none", &json!({})).unwrap();
app.ui.view.rulers = true;
app.select_tool("selection");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
frame(app, &ctx, vec![button(from, true)]);
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
frame(app, &ctx, vec![button(to, false)]);
};
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
// 3 px off the middle of the top and bottom sides: onto the line through them.
let mid = xf.to_screen(Point::new(200.0, 150.0));
drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0));
// 3 px inside the artboard's bottom edge: onto it.
let bottom = xf.to_screen(Point::new(100.0, 300.0));
drag(&mut app, pos2(bottom.x, rect.top() - RULER / 2.0), bottom - vec2(0.0, 3.0));
assert_eq!(guides(&app), [(true, 200.0), (false, 300.0)]);
assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "New Guide");
// An artboard guide runs from the artboard's left edge to its right edge.
app.session.execute("guide.add", &json!({"vertical": false, "pos": 75, "artboard": 0})).unwrap();
let s = shapes(&mut app, &ctx);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let (a, b) = (xf.to_screen(Point::new(0.0, 75.0)), xf.to_screen(Point::new(400.0, 75.0)));
let at = |p: Pos2, q: Pos2| (p - q).length() < 0.01;
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } if at(points[0], a) && at(points[1], b))), "across the artboard");
// A canvas guide crosses the whole window.
let y = xf.to_screen(Point::new(0.0, 300.0)).y;
let window = app.canvas_rect.unwrap();
assert!(
s.iter().any(
|s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y))
)
);
}
/// #414: guides dragged out of the rulers are picked and dragged with the Selection tool
/// (highlighted while selected), go with Delete or Backspace, and dragged back onto their
/// ruler.
@@ -1949,6 +2045,32 @@ mod tests {
assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them");
}
/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer
/// shows with Smart Guides on and the option on, and not otherwise.
#[test]
fn object_highlighting_preference_hides_the_hover_outline() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
// The hover outline: 1.5 px lines in the layer colour.
let outlines = |app: &mut VectorcraftApp| {
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
};
assert!(outlines(&mut app) > 0, "highlighted by default");
app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": false})).unwrap();
assert_eq!(outlines(&mut app), 0, "the option off");
app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": true})).unwrap();
app.ui.view.smart_guides = false;
assert_eq!(outlines(&mut app), 0, "Smart Guides off");
}
/// Hide Corner Widget for angles greater than (#394): a rectangle's 90° corners lose their
/// widgets below 90°.
#[test]
@@ -1965,9 +2087,34 @@ mod tests {
assert_eq!(widgets(&mut app), 0);
}
/// The type widget beside selected type's bounding box: hollow (white) on point type, filled
/// in the layer colour on area type.
#[test]
fn the_type_widget_shows_which_kind_the_type_is() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let layer = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)));
let ctx = egui::Context::default();
let widget = |app: &mut VectorcraftApp| -> Vec<Color32> {
let r = vectorcraft_tools::typewidget::RADIUS_PX;
shapes(app, &ctx)
.iter()
.filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill != Color32::TRANSPARENT).then_some(c.fill) } else { None })
.collect()
};
assert_eq!(widget(&mut app), vec![Color32::WHITE]);
app.session.execute("type.convertToAreaType", &json!({})).unwrap();
assert_eq!(widget(&mut app), vec![layer]);
app.ui.view.bounding_box = false;
assert!(widget(&mut app).is_empty(), "no bounding box, no widget");
}
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
/// scrolls up and down and Cmd/Ctrl-wheel sideways.
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
/// trackpad pinch zoom about the pointer either way.
#[test]
fn zoom_with_mouse_wheel_preference() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -1983,10 +2130,19 @@ mod tests {
run(&mut app);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
// One notch up at `at`: → (zoom ratio, scroll in points, how far the point under `at` moved).
let turn = |app: &mut VectorcraftApp, shift: bool, cmd: bool| {
// One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how
// far the point under `at` moved).
let turn = |app: &mut VectorcraftApp, mods: &str| {
let before = *app.view().unwrap();
crate::tests_synthetic::control(app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "shift": shift, "cmd": cmd}));
if mods == "pinch" {
app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]);
} else {
let mut p = json!({"x": at.x, "y": at.y, "dy": 1});
for m in mods.split('+').filter(|m| !m.is_empty()) {
p[m] = json!(true);
}
crate::tests_synthetic::control(app, &ctx, "ui.wheel", p);
}
// egui spreads a notch over a few frames.
for _ in 0..40 {
run(app);
@@ -1995,16 +2151,19 @@ mod tests {
let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at));
(after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved)
};
let (zoom, scroll, _) = turn(&mut app, false, false);
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6;
let (zoom, scroll, _) = turn(&mut app, "");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})");
let (zoom, _, moved) = turn(&mut app, false, true);
assert!(zoom > 1.01 && moved < 1e-6, "off: Cmd-wheel zooms in about the pointer ({zoom}, {moved})");
assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer");
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
let (zoom, _, moved) = turn(&mut app, false, false);
assert!(zoom > 1.01 && moved < 1e-6, "on: the wheel zooms in about the pointer ({zoom}, {moved})");
let (zoom, scroll, _) = turn(&mut app, true, false);
assert!(zooms_in(turn(&mut app, "")), "on: the wheel zooms in about the pointer");
assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel");
assert!(zooms_in(turn(&mut app, "pinch")), "on: and a pinch");
let (zoom, scroll, _) = turn(&mut app, "shift");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: Shift-wheel scrolls up ({zoom}, {scroll:?})");
let (zoom, scroll, _) = turn(&mut app, false, true);
let (zoom, scroll, _) = turn(&mut app, "cmd");
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
}
+66 -19
View File
@@ -105,6 +105,47 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// An anchor button: icon, tooltip, command and the convert command's `to`.
type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>);
/// What the Control bar and the Properties panel offer for direct-selected anchors: "Convert:"
/// corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a row of its
/// own: inline in the Control bar, one under the other in a panel.
pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let mut run = None;
let groups: [(&str, &[AnchorButton]); 2] = [
(
tl!("Convert:"),
&[
("dc-anchor", tl!("Convert Selected Anchor Points to Corner"), "path.convertAnchors", Some("corner")),
("dc-anchor-smooth", tl!("Convert Selected Anchor Points to Smooth"), "path.convertAnchors", Some("smooth")),
],
),
(
tl!("Anchors:"),
&[
("pen-tool-delete", tl!("Remove Anchor Points"), "path.removeAnchors", None),
("dc-join", tl!("Connect Selected End Points"), "path.join", None),
("scissors", tl!("Cut Path at Selected Anchor Points"), "path.cutAtAnchors", None),
],
),
];
for (label, buttons) in groups {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).size(12.0).color(t.text));
for &(icon, tip, id, to) in buttons {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to }))));
}
}
});
}
if let Some((id, p)) = run {
crate::menus::invoke(app, id, p);
}
}
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -135,32 +176,35 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
tl!("Embedded")
}
}
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"),
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
if anchor_mode && widgets::icon_button(ui, "dc-pen-delete", "Remove Anchor Points", false, 24.0).clicked() {
crate::menus::invoke(app, "path.removeAnchors", json!({}));
if anchor_mode {
anchor_buttons(app, ui);
}
ui.add_space(6.0);
crate::place::control_bar_details(app, ui);
// An image or an Image Trace object shows its own controls in place of Fill and Stroke.
let image = crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui);
crate::toolbar::control_bar_options(app, ui);
crate::dialogs::envelope::control_bar(app, ui);
let shown_stroke = crate::panels::current_stroke(app);
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
crate::panels::paint_chip(app, ui, false, 22.0, true);
crate::panels::paint_chip(app, ui, true, 22.0, true);
// The link opens the Stroke panel as a popover under it; then the weight spinner
// (with presets) and the width profile.
let link = ui.link(egui::RichText::new(tl!("Stroke:")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
stroke_panel::popover(app, &link);
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
app.run("stroke.set", json!({"profile": id})).ok();
if !image {
let shown_stroke = crate::panels::current_stroke(app);
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
crate::panels::paint_chip(app, ui, false, 22.0, true);
crate::panels::paint_chip(app, ui, true, 22.0, true);
// The link opens the Stroke panel as a popover under it; then the weight spinner
// (with presets) and the width profile.
stroke_panel::link(app, ui, tl!("Stroke:"));
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
app.run("stroke.set", json!({"profile": id})).ok();
}
ui.add_space(4.0);
ui.separator();
}
ui.add_space(4.0);
ui.separator();
if ui.link(egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
@@ -180,6 +224,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
}
ui.separator();
crate::panels::character::control_bar(app, ui);
if sel.is_empty() {
if widgets::flat_button(ui, tl!("Document Setup"), 112.0).clicked() {
app.run("file.documentSetup", json!({})).ok();
@@ -422,11 +467,13 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
// Background saves and exports in progress, else the last message.
if let Some(job) = app.background.jobs.first() {
let more = app.background.jobs.len() - 1;
let text = if more > 0 { format!("{}… (+{more})", job.label) } else { format!("{}…", job.label) };
let label = crate::i18n::msg(&job.label);
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
} else if !app.ui.status.is_empty() {
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
// The message stays English in `ui.status` (agents and tests read it).
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
}
});
});
+2
View File
@@ -77,6 +77,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"tool": app.session.tool_id(),
"toolOptions": app.session.tool_options(),
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
// Not saved with the UI state, so not in `ui`: 0 normal … 3 Presentation Mode.
"screenMode": app.ui.screen_mode,
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"window": [r.width(), r.height()],
+359 -50
View File
@@ -1,21 +1,44 @@
//! Tool cursors drawn as vector glyphs (egui only offers system cursors). Black shapes with a white
//! halo, hotspot at `p`, Illustrator's visual grammar: solid arrow (Selection), hollow arrow (Direct
//! Selection), pen nib with state badges, crosshair for drawing tools, curved arrows for rotate.
//!
//! The desktop app shows them as OS cursors ([`Images`]): the system moves those itself, at once,
//! while a cursor painted into the window follows the pointer a few frames late (#444).
use egui::{Color32, Painter, Pos2, Shape, Stroke, pos2, vec2};
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
use egui::{Color32, CustomCursorImage, Painter, Pos2, Shape, Stroke, Vec2, pos2, vec2};
use vectorcraft_tools::Cursor;
const INK: Color32 = Color32::BLACK;
const HALO: Color32 = Color32::WHITE;
fn poly(p: &Painter, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
/// The shapes of one cursor glyph.
#[derive(Default)]
struct Ink(Vec<Shape>);
impl Ink {
fn add(&mut self, s: Shape) {
self.0.push(s);
}
fn line_segment(&mut self, pts: [Pos2; 2], stroke: Stroke) {
self.add(Shape::line_segment(pts, stroke));
}
fn circle_stroke(&mut self, c: Pos2, r: f32, stroke: Stroke) {
self.add(Shape::circle_stroke(c, r, stroke));
}
fn circle_filled(&mut self, c: Pos2, r: f32, fill: Color32) {
self.add(Shape::circle_filled(c, r, fill));
}
}
fn poly(p: &mut Ink, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
// Halo first (thicker white outline), then the glyph.
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
p.add(Shape::closed_line(pts, Stroke::new(1.0, stroke)));
}
fn line(p: &Painter, a: Pos2, b: Pos2) {
fn line(p: &mut Ink, a: Pos2, b: Pos2) {
p.line_segment([a, b], Stroke::new(3.0, HALO));
p.line_segment([a, b], Stroke::new(1.2, INK));
}
@@ -25,7 +48,7 @@ fn arrow_points(o: Pos2) -> Vec<Pos2> {
[(0.0, 0.0), (0.0, 15.0), (3.8, 11.4), (6.4, 17.0), (8.6, 16.0), (6.1, 10.6), (11.0, 10.6)].iter().map(|(x, y)| o + vec2(*x, *y)).collect()
}
fn arrow(p: &Painter, o: Pos2, hollow: bool) {
fn arrow(p: &mut Ink, o: Pos2, hollow: bool) {
let pts = arrow_points(o);
// The arrow is concave: draw as a filled mesh of two convex parts, then outline.
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
@@ -35,7 +58,7 @@ fn arrow(p: &Painter, o: Pos2, hollow: bool) {
p.add(Shape::closed_line(pts, Stroke::new(1.0, INK)));
}
fn crosshair(p: &Painter, o: Pos2) {
fn crosshair(p: &mut Ink, o: Pos2) {
for (a, b) in
[(vec2(-9.0, 0.0), vec2(-2.0, 0.0)), (vec2(2.0, 0.0), vec2(9.0, 0.0)), (vec2(0.0, -9.0), vec2(0.0, -2.0)), (vec2(0.0, 2.0), vec2(0.0, 9.0))]
{
@@ -43,7 +66,7 @@ fn crosshair(p: &Painter, o: Pos2) {
}
}
fn pen(p: &Painter, o: Pos2, badge: &str) {
fn pen(p: &mut Ink, o: Pos2, badge: &str) {
// Nib pointing to the top-left hotspot.
let pts = vec![o, o + vec2(4.0, 12.0), o + vec2(8.0, 16.0), o + vec2(16.0, 8.0), o + vec2(12.0, 4.0)];
poly(p, pts, INK, INK);
@@ -74,7 +97,7 @@ fn pen(p: &Painter, o: Pos2, badge: &str) {
}
}
fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
let d = dir.normalized() * 8.0;
let n = vec2(-d.y, d.x) * 0.45;
line(p, o - d, o + d);
@@ -83,7 +106,7 @@ fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
}
}
fn rotate(p: &Painter, o: Pos2) {
fn rotate(p: &mut Ink, o: Pos2) {
let pts: Vec<Pos2> = (0..=10)
.map(|i| {
let a = std::f32::consts::PI * (0.15 + 0.7 * i as f32 / 10.0);
@@ -98,7 +121,7 @@ fn rotate(p: &Painter, o: Pos2) {
}
/// Live Corners: the hollow arrow with a rounded-corner badge.
fn corner_radius(p: &Painter, o: Pos2) {
fn corner_radius(p: &mut Ink, o: Pos2) {
arrow(p, o, true);
let b = o + vec2(12.0, 13.0);
let mut pts = vec![b + vec2(0.0, 10.0)];
@@ -113,7 +136,7 @@ fn corner_radius(p: &Painter, o: Pos2) {
/// Over a bracket of type on a path: the arrow with a bracket (a stem standing on a baseline,
/// with a foot) below right.
fn path_bracket(p: &Painter, o: Pos2) {
fn path_bracket(p: &mut Ink, o: Pos2) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
let stem = [b + vec2(3.0, 0.0), b + vec2(3.0, 10.0)];
@@ -126,9 +149,17 @@ fn path_bracket(p: &Painter, o: Pos2) {
}
}
/// Over the type widget: the arrow with a type badge (a T) below right.
fn type_widget(p: &mut Ink, o: Pos2) {
arrow(p, o, false);
let b = o + vec2(12.0, 13.0);
line(p, b, b + vec2(8.0, 0.0));
line(p, b + vec2(4.0, 0.0), b + vec2(4.0, 9.0));
}
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
/// badge.
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
fn stop_badge(p: &mut Ink, o: Pos2, add: bool) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
line(p, b, b + vec2(6.0, 0.0));
@@ -138,14 +169,14 @@ fn stop_badge(p: &Painter, o: Pos2, add: bool) {
}
/// A slice badge: a small rectangle cut by a line, at `b` (its top left).
fn slice_badge(p: &Painter, b: Pos2) {
fn slice_badge(p: &mut Ink, b: Pos2) {
let pts = vec![b, b + vec2(8.0, 0.0), b + vec2(8.0, 6.0), b + vec2(0.0, 6.0)];
poly(p, pts, HALO, INK);
line(p, b + vec2(4.0, 0.0), b + vec2(4.0, 6.0));
}
/// The Slice tool: a crosshair with a blade below right of the hotspot.
fn slice(p: &Painter, o: Pos2) {
fn slice(p: &mut Ink, o: Pos2) {
crosshair(p, o);
let b = o + vec2(7.0, 7.0);
poly(p, vec![b, b + vec2(9.0, 4.0), b + vec2(10.0, 7.0), b + vec2(3.0, 6.0)], HALO, INK);
@@ -155,7 +186,7 @@ fn slice(p: &Painter, o: Pos2) {
/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point),
/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point
/// (a drag moves or widens it).
fn width(p: &Painter, o: Pos2, badge: &str) {
fn width(p: &mut Ink, o: Pos2, badge: &str) {
arrow(p, o, true);
let b = o + vec2(11.0, 18.0);
let top: Vec<Pos2> = (0..=8)
@@ -179,7 +210,7 @@ fn width(p: &Painter, o: Pos2, badge: &str) {
}
}
fn ibeam(p: &Painter, o: Pos2) {
fn ibeam(p: &mut Ink, o: Pos2) {
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
line(p, o + vec2(-3.0, 8.0), o + vec2(3.0, 8.0));
@@ -188,7 +219,7 @@ fn ibeam(p: &Painter, o: Pos2) {
/// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art,
/// filled over an object; over an anchor point a ringed dot (the blend starts there).
fn blend(p: &Painter, o: Pos2, badge: Cursor) {
fn blend(p: &mut Ink, o: Pos2, badge: Cursor) {
crosshair(p, o);
let b = o + vec2(9.0, 9.0);
if badge == Cursor::BlendAnchor {
@@ -201,51 +232,329 @@ fn blend(p: &Painter, o: Pos2, badge: Cursor) {
poly(p, vec![b, b + vec2(7.0, 0.0), b + vec2(7.0, 7.0), b + vec2(0.0, 7.0)], fill, INK);
}
/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should
/// stay system cursors (hand, zoom, busy states).
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
/// The Shape Builder: a crosshair with a plus badge below right of the hotspot (merge mode), or a
/// minus (erase mode).
fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) {
crosshair(p, o);
let c = o + vec2(12.0, 12.0);
line(p, c - vec2(3.5, 0.0), c + vec2(3.5, 0.0));
if !erase {
line(p, c - vec2(0.0, 3.5), c + vec2(0.0, 3.5));
}
}
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
/// (hand, zoom, busy states).
fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
let ink = &mut Ink::default();
match c {
Cursor::Arrow => arrow(painter, p, false),
Cursor::ArrowHollow => arrow(painter, p, true),
Cursor::Arrow => arrow(ink, p, false),
Cursor::ArrowHollow => arrow(ink, p, true),
Cursor::Move => {
arrow(painter, p, false);
double_arrow(painter, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
arrow(ink, p, false);
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
}
Cursor::Crosshair | Cursor::Eyedropper => crosshair(painter, p),
Cursor::ResizeH => double_arrow(painter, p, vec2(1.0, 0.0)),
Cursor::ResizeV => double_arrow(painter, p, vec2(0.0, 1.0)),
Cursor::ResizeNwSe => double_arrow(painter, p, vec2(1.0, 1.0)),
Cursor::ResizeNeSw => double_arrow(painter, p, vec2(1.0, -1.0)),
Cursor::Rotate => rotate(painter, p),
Cursor::CornerRadius => corner_radius(painter, p),
Cursor::Pen => pen(painter, p, "*"),
Cursor::PenAdd => pen(painter, p, "+"),
Cursor::PenDelete => pen(painter, p, "-"),
Cursor::PenClose => pen(painter, p, "o"),
Cursor::PenContinue => pen(painter, p, "/"),
Cursor::PenConvert => pen(painter, p, "^"),
Cursor::Text => ibeam(painter, p),
Cursor::AddStop => stop_badge(painter, p, true),
Cursor::RemoveStop => stop_badge(painter, p, false),
Cursor::Slice => slice(painter, p),
Cursor::Crosshair | Cursor::Eyedropper => crosshair(ink, p),
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
Cursor::Rotate => rotate(ink, p),
Cursor::CornerRadius => corner_radius(ink, p),
Cursor::Pen => pen(ink, p, "*"),
Cursor::PenAdd => pen(ink, p, "+"),
Cursor::PenDelete => pen(ink, p, "-"),
Cursor::PenClose => pen(ink, p, "o"),
Cursor::PenContinue => pen(ink, p, "/"),
Cursor::PenConvert => pen(ink, p, "^"),
Cursor::Text => ibeam(ink, p),
Cursor::AddStop => stop_badge(ink, p, true),
Cursor::RemoveStop => stop_badge(ink, p, false),
Cursor::Slice => slice(ink, p),
Cursor::SliceSelect => {
arrow(painter, p, false);
slice_badge(painter, p + vec2(11.0, 14.0));
arrow(ink, p, false);
slice_badge(ink, p + vec2(11.0, 14.0));
}
Cursor::Width => width(ink, p, ""),
Cursor::WidthAdd => width(ink, p, "+"),
Cursor::WidthPoint => width(ink, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(ink, p, c),
Cursor::PathBracket => path_bracket(ink, p),
Cursor::TypeWidget => type_widget(ink, p),
Cursor::ShapeBuilder => shape_builder(ink, p, false),
Cursor::ShapeBuilderErase => shape_builder(ink, p, true),
_ => return None,
}
Some(std::mem::take(&mut ink.0))
}
/// Paint cursor `c` at `p` on the given (foreground) painter, where the window can't show it as an
/// OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay system cursors.
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
glyph(c, p).map(|shapes| painter.extend(shapes)).is_some()
}
/// Does the window show the glyphs as OS cursors ([`Images`])? Not on macOS, which sizes a cursor
/// bitmap in points (a Retina one would show twice as big, or blurred), nor on the web (eframe has no
/// bitmap cursors there): those paint them into the window ([`paint`]).
pub const OS_CURSORS: bool = !cfg!(any(target_os = "macos", target_arch = "wasm32"));
/// The cursor bitmaps handed to the OS, by (cursor, pixels per point): each glyph is rasterized
/// once, and handing the same `Arc` every frame keeps the window's OS cursor instead of making a
/// new one.
#[derive(Default)]
pub struct Images(Vec<(Cursor, f32, CustomCursorImage)>);
impl Images {
/// Cursor `c` for a display of `ppp` physical pixels per point; `None` for cursors that stay
/// system cursors.
pub fn get(&mut self, c: Cursor, ppp: f32) -> Option<CustomCursorImage> {
if let Some((.., image)) = self.0.iter().find(|(k, s, _)| *k == c && *s == ppp) {
return Some(image.clone());
}
let image = rasterize(c, ppp)?;
// A few dozen glyphs at the scales of the displays the window visited.
if self.0.len() >= 128 {
self.0.clear();
}
self.0.push((c, ppp, image.clone()));
Some(image)
}
}
/// The largest side of a cursor bitmap, in pixels (the glyphs span under 50 points).
const MAX_SIDE: f32 = 256.0;
/// Cursor `c` as a straight-alpha RGBA bitmap at `ppp` pixels per point, tessellated (anti-aliased)
/// as egui would paint it, the hotspot on a pixel corner.
fn rasterize(c: Cursor, ppp: f32) -> Option<CustomCursorImage> {
if !(ppp.is_finite() && ppp > 0.0) {
return None;
}
let shapes = glyph(c, Pos2::ZERO)?;
let mut tessellator = Tessellator::new(ppp, TessellationOptions::default(), [1, 1], vec![]);
let mut mesh = Mesh::default();
for shape in shapes {
tessellator.tessellate_shape(shape, &mut mesh);
}
let bounds = mesh.calc_bounds();
// The hotspot must lie inside the bitmap.
let min = (bounds.min.to_vec2() * ppp).floor().min(Vec2::ZERO);
let max = (bounds.max.to_vec2() * ppp).ceil().max(Vec2::splat(1.0));
let size = max - min;
if !(size.x <= MAX_SIDE && size.y <= MAX_SIDE) {
return None;
}
let (w, h) = (size.x as usize, size.y as usize);
let mut px = vec![[0.0; 4]; w * h];
let corner = |v: &Vertex| (v.pos.to_vec2() * ppp - min, v.color);
for tri in mesh.indices.chunks_exact(3) {
let &[i, j, k] = tri else { continue };
if let (Some(a), Some(b), Some(c)) = (mesh.vertices.get(i as usize), mesh.vertices.get(j as usize), mesh.vertices.get(k as usize)) {
fill_triangle(&mut px, w, [corner(a), corner(b), corner(c)]);
}
}
let rgba: Vec<u8> = px
.iter()
.flat_map(|&[r, g, b, a]| {
let k = if a > 0.0 { 1.0 / a } else { 0.0 };
[r * k, g * k, b * k, a].map(|x| (x * 255.0).round().clamp(0.0, 255.0) as u8)
})
.collect();
Some(CustomCursorImage { rgba: rgba.into(), size: [w as u16, h as u16], hotspot: [-min.x as u16, -min.y as u16] })
}
/// Blend triangle `t` (pixel positions and premultiplied colours, interpolated across it) over `px`
/// (premultiplied RGBA, `w` pixels a row) at the pixel centres it covers, as the GPU blends egui's
/// meshes. A centre on an edge goes to one of the two triangles sharing it, so seams aren't doubled.
fn fill_triangle(px: &mut [[f32; 4]], w: usize, t: [(Vec2, Color32); 3]) {
let edge = |a: Vec2, b: Vec2, p: Vec2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
let [a, mut b, mut c] = t;
let mut area = edge(a.0, b.0, c.0);
if area < 0.0 {
std::mem::swap(&mut b, &mut c);
area = -area;
}
if area.is_nan() || area <= 1e-6 || w == 0 {
return;
}
// Of the two triangles sharing an edge (walking it in opposite directions), one owns it.
let owns = |u: Vec2, v: Vec2| v.y > u.y || (v.y == u.y && v.x > u.x);
let h = px.len() / w;
let lo = a.0.min(b.0).min(c.0).max(Vec2::ZERO);
let hi = a.0.max(b.0).max(c.0).min(vec2(w as f32, h as f32));
for y in lo.y.floor() as usize..hi.y.ceil() as usize {
for x in lo.x.floor() as usize..hi.x.ceil() as usize {
let p = vec2(x as f32 + 0.5, y as f32 + 0.5);
let weights = [(b.0, c.0), (c.0, a.0), (a.0, b.0)].map(|(u, v)| (edge(u, v, p), owns(u, v)));
if !weights.iter().all(|&(e, own)| e > 0.0 || (e == 0.0 && own)) {
continue;
}
let Some(dst) = px.get_mut(y * w + x) else { continue };
let [wa, wb, wc] = weights.map(|(e, _)| e / area);
let src: [f32; 4] = std::array::from_fn(|i| {
let channel = |col: Color32| f32::from(col.to_array()[i]);
(wa * channel(a.1) + wb * channel(b.1) + wc * channel(c.1)) / 255.0
});
let keep = 1.0 - src[3];
*dst = std::array::from_fn(|i| src[i] + dst[i] * keep);
}
Cursor::Width => width(painter, p, ""),
Cursor::WidthAdd => width(painter, p, "+"),
Cursor::WidthPoint => width(painter, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
Cursor::PathBracket => path_bracket(painter, p),
_ => return false,
}
let _ = pos2;
true
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
/// Every cursor with a glyph.
const GLYPHS: [Cursor; 32] = [
Cursor::Arrow,
Cursor::ArrowHollow,
Cursor::Move,
Cursor::Crosshair,
Cursor::ResizeH,
Cursor::ResizeV,
Cursor::ResizeNwSe,
Cursor::ResizeNeSw,
Cursor::Rotate,
Cursor::CornerRadius,
Cursor::Pen,
Cursor::PenAdd,
Cursor::PenDelete,
Cursor::PenClose,
Cursor::PenContinue,
Cursor::PenConvert,
Cursor::Text,
Cursor::Eyedropper,
Cursor::AddStop,
Cursor::RemoveStop,
Cursor::Slice,
Cursor::SliceSelect,
Cursor::Width,
Cursor::WidthAdd,
Cursor::WidthPoint,
Cursor::Blend,
Cursor::BlendObject,
Cursor::BlendAnchor,
Cursor::PathBracket,
Cursor::TypeWidget,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
];
/// The straight RGBA of pixel (x, y).
fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] {
let i = (y * img.size[0] as usize + x) * 4;
[img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]]
}
#[test]
fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() {
for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] {
for c in GLYPHS {
let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}"));
let [w, h] = img.size.map(usize::from);
assert_eq!(img.rgba.len(), w * h * 4, "{c:?}");
assert!(img.hotspot[0] < img.size[0] && img.hotspot[1] < img.size[1], "{c:?} at {ppp}: {img:?}");
assert!(w as f32 <= 50.0 * ppp && h as f32 <= 50.0 * ppp, "{c:?} at {ppp}: {img:?}");
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255), "{c:?}: nothing opaque");
}
}
for c in [Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, Cursor::ZoomOut, Cursor::NotAllowed] {
assert!(rasterize(c, 1.0).is_none(), "{c:?} stays a system cursor");
}
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
}
}
#[test]
fn the_arrow_bitmap_is_black_in_a_white_halo_with_the_tip_on_the_hotspot() {
let img = rasterize(Cursor::Arrow, 1.0).unwrap();
let [hx, hy] = img.hotspot.map(usize::from);
// The tip covers the hotspot's pixel; the body is solid black, the outline's halo white.
assert!(pixel(&img, hx, hy)[3] > 0, "{:?}", pixel(&img, hx, hy));
let body = pixel(&img, hx + 2, hy + 8);
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo");
// Nothing left of or above the tip but the halo.
assert!((0..img.size[1] as usize).all(|y| pixel(&img, 0, y)[..3].iter().all(|&c| c > 100) || pixel(&img, 0, y)[3] < 128));
// At twice the pixels per point, twice the pixels.
let big = rasterize(Cursor::Arrow, 2.0).unwrap();
for i in 0..2 {
let ratio = f32::from(big.size[i]) / f32::from(img.size[i]);
assert!((1.8..=2.2).contains(&ratio), "{:?} vs {:?}", big.size, img.size);
}
let [bx, by] = big.hotspot.map(usize::from);
let body = pixel(&big, bx + 4, by + 16);
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
}
#[test]
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
let mut images = Images::default();
let a = images.get(Cursor::Pen, 1.5).unwrap();
let b = images.get(Cursor::Pen, 1.5).unwrap();
assert!(Arc::ptr_eq(&a.rgba, &b.rgba));
assert!(!Arc::ptr_eq(&a.rgba, &images.get(Cursor::Pen, 2.0).unwrap().rgba));
assert!(images.get(Cursor::Hand, 1.5).is_none());
assert!(images.get(Cursor::Arrow, f32::NAN).is_none());
assert_eq!(images.0.len(), 2);
}
/// One headless 800×600 frame of the whole window, a tenth of a second after the last.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> egui::FullOutput {
let time = Some(ctx.input(|i| i.time) + 0.1);
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time, events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
}
#[test]
fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx, vec![]);
}
let canvas = app.canvas_rect.unwrap();
let over = canvas.center();
let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
if OS_CURSORS {
let img = out.platform_output.cursor_image.as_ref().expect("the Selection tool's arrow as an OS cursor");
assert_eq!(img, &app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap());
// Where the OS can't show the bitmap, its system cursor.
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default);
} else {
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
// The same bitmap while the pointer moves (the OS keeps its cursor), and an idle window
// once it rests: no frame is asked for.
let next = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over + vec2(5.0, 3.0))]);
if OS_CURSORS {
assert!(Arc::ptr_eq(&next.platform_output.cursor_image.unwrap().rgba, &out.platform_output.cursor_image.unwrap().rgba));
}
let mut idle = frame(&mut app, &ctx, vec![]);
for _ in 0..3 {
idle = frame(&mut app, &ctx, vec![]);
}
let delay = idle.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay).unwrap();
assert!(delay > std::time::Duration::from_millis(100), "{delay:?} {:?}", ctx.repaint_causes());
// Off the canvas, the panels' cursors.
let off = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(canvas.left() - 20.0, canvas.center().y))]);
assert!(off.platform_output.cursor_image.is_none());
}
#[test]
fn arrow_hotspot_is_tip() {
+27 -3
View File
@@ -103,6 +103,31 @@ fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: boo
changed
}
/// The size of the colour chip before each name in the Color list.
const CHIP: egui::Vec2 = egui::vec2(16.0, 10.0);
/// The Color list: each preset's name after a chip of its colour, then Custom (no chip, its
/// name aligned with the others). Returns the chosen row; Custom is `LAYER_COLORS.len()`.
fn color_dropdown(ui: &mut egui::Ui, shown: &str) -> Option<usize> {
let border = crate::theme::Tokens::get(ui.ctx()).input_border;
let rows = LAYER_COLORS.iter().map(|(n, c)| (*n, Some(*c))).chain([("Custom", None)]);
widgets::combo(ui, "layer-color", tl!(shown), 140.0, false, |ui| {
let mut chosen = None;
for (i, (name, rgb)) in rows.enumerate() {
let chip = ui.id().with(("layer-color-chip", i));
let r = egui::Button::selectable(name == shown, (egui::Atom::custom(chip, CHIP), tl!(name))).atom_ui(ui);
if let (Some(rect), Some([cr, cg, cb])) = (r.rect(chip), rgb) {
ui.painter().rect_filled(rect, 1.0, egui::Color32::from_rgb(cr, cg, cb));
ui.painter().rect_stroke(rect, 1.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Inside);
}
if r.response.clicked() {
chosen = Some(i);
}
}
chosen
})
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let layer = d.bool("layer");
let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1);
@@ -116,9 +141,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, tl!("Color:"));
ui.horizontal(|ui| {
let cur = d.str("color");
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
let shown = if LAYER_COLORS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&cur)) { cur.clone() } else { "Custom".into() };
if let Some((n, _)) = widgets::dropdown(ui, "layer-color", &shown, &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) {
let shown = LAYER_COLORS.iter().map(|(n, _)| *n).find(|n| n.eq_ignore_ascii_case(&cur)).unwrap_or("Custom");
if let Some((n, _)) = color_dropdown(ui, shown).and_then(|i| LAYER_COLORS.get(i)) {
d.fields.insert("color".into(), json!(n));
}
let [r, g, b] = LAYER_COLORS
+108
View File
@@ -13,6 +13,92 @@ pub struct Catalog {
ids: HashMap<String, String>,
/// plural messages: `one|other` → forms
plurals: HashMap<String, Vec<String>>,
/// status and error messages without placeholders (`@msg`): English → translation
messages: HashMap<String, String>,
/// status and error messages with `{name}` placeholders (`@msg`), most specific first
templates: Vec<Template>,
}
/// A piece of a message template: literal text or a `{name}` placeholder.
#[derive(Debug)]
enum Piece {
Text(String),
Hole(String),
}
/// A message template (`Couldn't open {name}: {e}`) and its translation.
#[derive(Debug)]
struct Template {
pieces: Vec<Piece>,
/// Bytes of literal text: a template with more of it is the more specific match.
literal: usize,
translation: String,
}
/// Messages longer than this are shown as they are (template matching backtracks).
const MAX_MESSAGE_LEN: usize = 2000;
impl Template {
/// `None` for a template that can't be matched reliably: no literal text, or two placeholders
/// in a row (where one ends and the next begins is unknowable).
fn parse(source: &str, translation: String) -> Option<Template> {
let mut pieces = Vec::new();
let mut rest = source;
while let Some(a) = rest.find('{') {
let after = rest.get(a + 1..)?;
let b = after.find('}')?;
if a > 0 {
pieces.push(Piece::Text(rest.get(..a)?.to_string()));
} else if matches!(pieces.last(), Some(Piece::Hole(_))) {
return None;
}
pieces.push(Piece::Hole(after.get(..b)?.to_string()));
rest = after.get(b + 1..)?;
}
if !rest.is_empty() {
pieces.push(Piece::Text(rest.to_string()));
}
let literal = pieces.iter().map(|p| if let Piece::Text(t) = p { t.len() } else { 0 }).sum();
(literal > 0).then_some(Template { pieces, literal, translation })
}
/// The placeholder values if `s` is an instance of this template.
fn captures<'a>(&self, s: &'a str) -> Option<Vec<(&str, &'a str)>> {
let mut out = Vec::new();
match_pieces(&self.pieces, s, &mut out).then_some(out)
}
}
/// Match `s` against `pieces`, a placeholder taking the shortest non-empty text that lets the
/// rest match (the last one takes what remains).
fn match_pieces<'p, 'a>(pieces: &'p [Piece], s: &'a str, out: &mut Vec<(&'p str, &'a str)>) -> bool {
let Some((first, rest)) = pieces.split_first() else { return s.is_empty() };
match first {
Piece::Text(t) => s.strip_prefix(t.as_str()).is_some_and(|tail| match_pieces(rest, tail, out)),
Piece::Hole(name) => {
let Some((next, after)) = rest.split_first() else {
if s.is_empty() {
return false;
}
out.push((name.as_str(), s));
return true;
};
let Piece::Text(next) = next else { return false };
// Every place the next literal occurs, nearest first (the hole is never empty).
let mut from = s.chars().next().map_or(1, char::len_utf8);
while let Some(at) = s.get(from..).and_then(|tail| tail.find(next.as_str())).map(|i| i + from) {
let (Some(value), Some(tail)) = (s.get(..at), s.get(at + next.len()..)) else { return false };
let mark = out.len();
out.push((name.as_str(), value));
if match_pieces(after, tail, out) {
return true;
}
out.truncate(mark);
from = at + next.chars().next().map_or(1, char::len_utf8);
}
false
}
}
}
/// A catalog entry as read from the file: (context, source, translation).
@@ -74,11 +160,20 @@ impl Catalog {
"@plural" => {
c.plurals.insert(src, tr.split('|').map(str::to_string).collect());
}
"@msg" => {
if src.contains('{') {
c.templates.extend(Template::parse(&src, tr));
} else {
c.messages.insert(src, tr);
}
}
_ => {
c.contextual.insert(format!("{ctx}\u{1}{src}"), tr);
}
}
}
// Most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`.
c.templates.sort_by_key(|t| std::cmp::Reverse(t.literal));
c
}
@@ -94,6 +189,19 @@ impl Catalog {
self.ids.get(id).map(String::as_str)
}
/// A status or error message: an exact `@msg` entry, else the first template it is an instance
/// of, with the placeholder values filled in. The values are returned for the caller to
/// translate (a reason after `…: {e}` is often a message of its own).
pub fn message<'a>(&self, s: &'a str) -> Option<(&str, Vec<(&str, &'a str)>)> {
if let Some(tr) = self.messages.get(s) {
return Some((tr.as_str(), Vec::new()));
}
if s.len() > MAX_MESSAGE_LEN {
return None;
}
self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.translation.as_str(), caps)))
}
/// The plural form `index` of the message whose English forms are `one|other`.
pub fn plural(&self, one: &str, other: &str, index: usize) -> Option<&str> {
let forms = self.plurals.get(&format!("{one}|{other}"))?;
+7 -1
View File
@@ -11,6 +11,7 @@
# names.
About VectorCraft O aplikaci VectorCraft
Align to Path Zarovnat k cestě
Cut Path at Selected Anchor Points Rozdělit cestu ve vybraných kotevních bodech
Join Our Discord Připojit se k našemu Discordu
Settings… Nastavení…
Language Jazyk
@@ -132,6 +133,7 @@
Expand… Rozložit…
Expand Appearance Rozložit vzhled
Crop Image Oříznout obraz
Mask Maska
Rasterize… Rastrovat…
Create Gradient Mesh… Vytvořit přechodovou síť…
Create Object Mosaic… Vytvořit mozaiku objektů…
@@ -491,7 +493,7 @@
Tools Nástroje
Toolbars Panely nástrojů
Advanced Rozšířený
Single / Double Column Jeden / dva sloupce
Double Column Dva sloupce
Actions Akce
Asset Export Export prvků
Attributes Atributy
@@ -604,3 +606,7 @@
Version Verze
% of all commits % všech commitů
Lines +/− Řádky +/−
Drag to move Přetažením přesunete
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
Put back in the toolbar Vrátit do panelu nástrojů
Float Tool Group Plovoucí skupina nástrojů
Can't render this file because it contains an unexpected character in line 2 and column 73.
+529 -1
View File
@@ -11,6 +11,7 @@
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
# leading/trailing spaces of fragments exactly as they are.
@@ -123,6 +124,7 @@
Anchor Points Puntos de ancla
Anchor Points, Handle, and Bounding Box Display Visualización de puntos de ancla, manejadores y cuadro delimitador
Anchor/Path Labels Etiquetas de ancla/trazado
Anchors: Anclas:
Angle Ángulo
Angle: Ángulo:
Angles Ángulos
@@ -426,6 +428,7 @@
Compression: Compresión:
Condition identifier: Identificador de condición:
Conflict {n} of {count} Conflicto {n} de {count}
Connect Selected End Points Conectar los puntos finales seleccionados
Consolidate All Windows Consolidar todas las ventanas
Constrain Angle Restringir ángulo
Constrain Width and Height Proportions Restringir proporciones de anchura y altura
@@ -443,6 +446,8 @@
Convert Color Convertir color
Convert Document Color Mode Convertir modo de color del documento
Convert Process to Global Convertir cuatricromía en global
Convert Selected Anchor Points to Corner Convertir los puntos de ancla seleccionados en esquina
Convert Selected Anchor Points to Smooth Convertir los puntos de ancla seleccionados en suaves
Convert text to outlines Convertir texto en contornos
Convert To Area Type Convertir en texto de área
Convert to Artboards Convertir en mesas de trabajo
@@ -453,6 +458,7 @@
Convert to RGB Convertir a RGB
Convert to Shape Convertir en forma
Convert to sRGB Convertir a sRGB
Convert: Convertir:
Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
Copies: Copias:
Copy Copiar
@@ -475,6 +481,7 @@
Corners Esquinas
Chamfer Chaflán
Corners… Esquinas…
Cut Path Cortar trazado
Descender Descendente
Flip Voltear
Inverted Round Redondeada invertida
@@ -510,6 +517,7 @@
Created: Creado:
Crop Recortar
Crop Image Recortar imagen
Mask Máscara
Crop Marks Marcas de corte
Crop To Recortar a
Crystallize Tool Herramienta Cristalizar
@@ -694,6 +702,8 @@
Duplicate Duplicar
Duplicate and Offset 10 pt Duplicar y desplazar 10 pt
Duplicate Artboard Duplicar mesa de trabajo
Copy Artboard Copiar mesa de trabajo
Cut Artboard Cortar mesa de trabajo
Duplicate Artboards Duplicar mesas de trabajo
Duplicate Brush Duplicar pincel
Duplicate Character Style Duplicar estilo de carácter
@@ -1140,6 +1150,8 @@
Image Map: Mapa de imagen:
Image Size Tamaño de imagen
Image Trace Calco de imagen
Image Tracing Objeto de calco de imagen
Tracing Presets Preajustes de calco
Image Trace Presets Preajustes de calco de imagen
Image: Imagen:
Images: Imágenes:
@@ -2427,7 +2439,7 @@
Simplify… Simplificar…
Simulate Colored Paper Simular papel de color
Simulate Paper Simular papel
Single / Double Column Columna simple / doble
Double Column Columna doble
Single Left Quotation Mark Comilla simple de apertura
Single Quotes: Comillas simples:
Single Right Quotation Mark Comilla simple de cierre
@@ -2527,6 +2539,8 @@
Stroke (X), double-click for the Color Picker Trazo (X), doble clic para el selector de color
Stroke Color Color de trazo
Stroke Options Opciones de trazo
Character: Carácter:
Character options Opciones de carácter
Stroke options Opciones de trazo
Stroke Weight Grosor de trazo
Stroke: Trazo:
@@ -2664,6 +2678,7 @@
Total Width: Anchura total:
Touch Type Tool Herramienta Retocar texto
Trace Calcar
Trace the image with the Default preset Calcar la imagen con el preajuste Predeterminado
Tracking Espaciado
Trademark Symbol Símbolo de marca comercial
Transform Transformar
@@ -2990,6 +3005,8 @@
# Leading model (Paragraph panel menu, East Asian options).
Top-to-Top Leading Interlineado de superior a superior
Bottom-to-Bottom Leading Interlineado de inferior a inferior
Burasagari Burasagari
Force Forzar
# About › Contributors / Models (credits.rs)
About Acerca de
@@ -3029,3 +3046,514 @@
axis Horizontal Horizontal
axis Vertical Vertical
axis Both Ambos
Drag to move Arrastrar para mover
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
Put back in the toolbar Devolver a la barra de herramientas
Float Tool Group Hacer flotar el grupo de herramientas
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
@msg `to` must be "corner" or "smooth" `to` debe ser "corner" o "smooth"
@msg BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray
@msg Blend colors: select at least three objects with filled colours Fusionar colores: selecciona al menos tres objetos con relleno de color
@msg Combine Slices: the slices have no bounds Combinar sectores: los sectores no tienen límites
@msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área
@msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados
@msg Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
@msg Corners edits a live rectangle Esquinas edita un rectángulo interactivo
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
@msg Couldn't place {name}: the document it was dropped on was closed No se pudo colocar {name}: se cerró el documento en el que se soltó
@msg Couldn't read {name}: {e} No se pudo leer {name}: {e}
@msg Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e}
@msg Create Outlines: select text objects Crear contornos: selecciona objetos de texto
@msg Create from Guides: the document has no artboard Crear a partir de guías: el documento no tiene mesa de trabajo
@msg Create from Guides: the guides make more than {MAX_PIECES} slices Crear a partir de guías: las guías forman más de {MAX_PIECES} sectores
@msg Create from Selection: the selection has no area Crear a partir de la selección: la selección no tiene área
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Recuperación de datos no tiene dónde guardar copias: configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder)
@msg direct-select anchor points to cut at (not the end points of open paths) selecciona con Selección directa los puntos de ancla en los que cortar (no los puntos finales de trazados abiertos)
@msg Divide Objects Below: select a path Dividir objetos inferiores: selecciona un trazado
@msg Divide Objects Below: select exactly one path Dividir objetos inferiores: selecciona exactamente un trazado
@msg Downloaded {_1} file(s) Se descargaron {_1} archivo(s)
@msg Downloaded {what} Se descargó {what}
@msg Expand Appearance: the selection has a basic appearance Expandir apariencia: la selección tiene una apariencia básica
@msg Expand: no brush strokes selected Expandir: no hay trazos de pincel seleccionados
@msg Expand: no gradient fills Expandir: no hay rellenos de degradado
@msg Expand: nothing to expand Expandir: no hay nada que expandir
@msg Exported {count} file(s) to {folder} Se exportaron {count} archivo(s) a {folder}
@msg Exported {path} Se exportó {path}
@msg Flatten Transparency: select objects to flatten Acoplar transparencia: selecciona los objetos que quieres acoplar
@msg Flatten Transparency: {e} Acoplar transparencia: {e}
@msg Image Trace: the traced image is missing Calco de imagen: falta la imagen calcada
@msg Imported {what}: {_1} Se importó {what}: {_1}
@msg Installed plug-in {_1} Se instaló el plugin {_1}
@msg Internal error: {msg} (please report this bug) Error interno: {msg} (por favor, informa de este error)
@msg Live Paint Merge: select a Live Paint group and the paths to add Combinar pintura interactiva: selecciona un grupo de pintura interactiva y los trazados que quieres agregar
@msg Live Paint: select paths or compound paths Pintura interactiva: selecciona trazados o trazados compuestos
@msg Live Paint: these paths are too degenerate to paint Pintura interactiva: estos trazados están demasiado degenerados para pintarlos
@msg Make Guides: select paths Crear guías: selecciona trazados
@msg No saved selections (Select → Save Selection…) No hay selecciones guardadas (Selección → Guardar selección…)
@msg No saved views (View → New View…) No hay vistas guardadas (Ver → Nueva vista…)
@msg Offset Path: select paths Desplazar trazado: selecciona trazados
@msg Opened with {_1} note(s): {_2} Se abrió con {_1} nota(s): {_2}
@msg Opened {url} Se abrió {url}
@msg Outline Stroke: no stroked paths selected Contornear trazo: no hay trazados con trazo seleccionados
@msg PDF settings are not an object los ajustes de PDF no son un objeto
@msg PDF writer error: {0} error del escritor de PDF: {0}
@msg Packaged {n} file(s) in {folder}{note} Se empaquetaron {n} archivo(s) en {folder}{note}
@msg Packaged {n} file(s){note} Se empaquetaron {n} archivo(s){note}
@msg Preview in Browser needs the desktop app (the web downloads files instead) Previsualizar en el navegador requiere la aplicación de escritorio (la versión web descarga los archivos)
@msg Release Guides: no guides Soltar guías: no hay guías
@msg Release: no layer to put the rectangle in Soltar: no hay ninguna capa donde poner el rectángulo
@msg Replace Spine: select a blend and a path Reemplazar eje: selecciona una fusión y un trazado
@msg select paths or anchor points selecciona trazados o puntos de ancla
@msg SVG parse error: {0} error al analizar el SVG: {0}
@msg Save for Web: the image is empty Guardar para web: la imagen está vacía
@msg Save writes {_1}, not {_2}: use document.export for other formats Guardar escribe {_1}, no {_2}: usa document.export para otros formatos
@msg Saved recovery data for {title} Se guardaron los datos de recuperación de {title}
@msg Saved to User Defined: {_1} Se guardó en Definido por el usuario: {_1}
@msg Saved {path} Se guardó {path}
@msg Select text or click into text with the Type tool to insert glyphs Selecciona texto o haz clic dentro del texto con la herramienta Texto para insertar glifos
@msg Shape Builder: no region under the pointer Generador de formas: no hay ninguna región bajo el puntero
@msg Shape Builder: select paths or compound paths Generador de formas: selecciona trazados o trazados compuestos
@msg Show All Hidden Attributes: nothing is hidden Mostrar todos los atributos ocultos: no hay nada oculto
@msg Split Into Grid: select paths large enough for the grid Dividir en cuadrícula: selecciona trazados lo bastante grandes para la cuadrícula
@msg Targa files are RGB: colorModel rgb los archivos Targa son RGB: colorModel rgb
@msg Text Wrap: select a wrap object Ceñir texto: selecciona un objeto para ceñir
@msg `aspect` must be a number (%) `aspect` debe ser un número (%)
@msg `at` must be [x, y] `at` debe ser [x, y]
@msg `color` must be a colour `color` debe ser un color
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` debe ser un comando cuyo elemento de menú abra un cuadro de diálogo (consulta ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` debe ser una lista
@msg `gradient` must be an object `gradient` debe ser un objeto
@msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list})
@msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5
@msg `stops` must be an array `stops` debe ser una lista
@msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats)
@msg `{_1}` is no save command `{_1}` no es un comando de guardado
@msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación
@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` no es un color (#rrggbb o el nombre de un preajuste)
@msg `{id}` has no envelope dialog `{id}` no tiene cuadro de diálogo de envolvente
@msg `{keep}` is not a side of this axis `{keep}` no es un lado de este eje
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` debe ser `auto` o un código de idioma como `en` o `zh-hant`
@msg `{key}` must be a #rrggbb colour `{key}` debe ser un color #rrggbb
@msg `{key}` must be a number `{key}` debe ser un número
@msg `{key}` must be a string `{key}` debe ser un texto
@msg `{key}` must be a whole number `{key}` debe ser un número entero
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` debe ser un número entero entre {min} y {max} (se recibió {x})
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` debe estar entre {min} y {max} (se recibió {x})
@msg `{key}` must be one of {_1} `{key}` debe ser uno de estos: {_1}
@msg `{key}` must be true or false `{key}` debe ser true o false
@msg `{k}` must be [x, y] `{k}` debe ser [x, y]
@msg `{name}` has no options `{name}` no tiene opciones
@msg `{name}` is a built-in workspace; choose another name `{name}` es un espacio de trabajo integrado; elige otro nombre
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` es un color de cuatricromía: los matices se aplican a colores globales y planos
@msg `{name}` is no recovery entry name `{name}` no es el nombre de una entrada de recuperación
@msg `{name}` isn't a colour swatch `{name}` no es una muestra de color
@msg `{path}` must be a bool or an object of bools `{path}` debe ser un booleano o un objeto de booleanos
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` no es un color (#rrggbb o el nombre de un preajuste)
@msg `{tool}` isn't a Liquify tool `{tool}` no es una herramienta de Licuar
@msg `{tool}` isn't a freehand tool `{tool}` no es una herramienta de dibujo a mano alzada
@msg `{to}` is not an available name `{to}` no es un nombre disponible
@msg a bitmap is empty un mapa de bits está vacío
@msg a bitmap is too large to read un mapa de bits es demasiado grande para leerlo
@msg a colour index is 0 to {_1}, not {n} un índice de color va de 0 a {_1}, no {n}
@msg a colour is an index, #rrggbb or a preset name, not {v} un color es un índice, #rrggbb o el nombre de un preajuste, no {v}
@msg a document needs at least one artboard un documento necesita al menos una mesa de trabajo
@msg a document needs at least one layer un documento necesita al menos una capa
@msg a freeform gradient keeps at least one point un degradado de forma libre conserva al menos un punto
@msg a gradient needs at least two stops un degradado necesita al menos dos paradas
@msg a graphic style named `{to}` already exists ya existe un estilo gráfico llamado `{to}`
@msg a page is missing falta una página
@msg a page out of range una página fuera de rango
@msg a page tree kid isn't a reference un hijo del árbol de páginas no es una referencia
@msg a page tree node has no kids un nodo del árbol de páginas no tiene hijos
@msg a page's placement is not finite la posición de una página no es finita
@msg a preset named `{_1}` exists ya existe un preajuste llamado `{_1}`
@msg a slice size must be a positive number of points el tamaño de un sector debe ser un número positivo de puntos
@msg a stream runs past the end un flujo se extiende más allá del final
@msg a stream's length is wrong la longitud de un flujo es incorrecta
@msg a stream's length isn't a number la longitud de un flujo no es un número
@msg add a format to export agrega un formato para exportar
@msg already editing a pattern ya se está editando un motivo
@msg an object can't be read no se puede leer un objeto
@msg an object in perspective would leave the edited grid un objeto en perspectiva saldría de la cuadrícula editada
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type
@msg artboard {0} does not exist la mesa de trabajo {0} no existe
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a esta escala el dibujo mide {w} × {h} pt, más de lo que cabe en el lienzo: elige una escala menor o ajústalo a la mesa de trabajo
@msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800"
@msg bad color {v} color no válido {v}
@msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb)
@msg bad page tree árbol de páginas no válido
@msg bad shortcut atajo no válido
@msg bad stream length longitud de flujo no válida
@msg bad trailer trailer no válido
@msg balance must be between 0 and 100 el equilibrio debe estar entre 0 y 100
@msg batch step {_1} (`{id}`) failed: {e} falló el paso {_1} del lote (`{id}`): {e}
@msg brightness must be dark|mediumDark|mediumLight|light el brillo debe ser dark|mediumDark|mediumLight|light
@msg can't encode the document: {0} no se puede codificar el documento: {0}
@msg can't parse shortcut `{c}` no se puede interpretar el atajo `{c}`
@msg can't parse shortcut `{s}` for `{k}` no se puede interpretar el atajo `{s}` de `{k}`
@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} no se puede guardar la versión {0}: VectorCraft escribe las versiones {MIN_VERSION} a {VERSION}
@msg can't write .{ext} images no se pueden escribir imágenes .{ext}
@msg cannot distort a zero-size bounding box no se puede distorsionar un cuadro delimitador de tamaño cero
@msg cannot read PDF: {0} no se puede leer el PDF: {0}
@msg cannot split an open path at its end point no se puede dividir un trazado abierto en su punto final
@msg check an asset to export marca un recurso para exportar
@msg choice must be "merge" or "add", not `{choice}` choice debe ser "merge" o "add", no `{choice}`
@msg choose a direction to divide in elige una dirección en la que dividir
@msg choose a folder to export to elige una carpeta a la que exportar
@msg choose a location for the package elige una ubicación para el paquete
@msg clipboard is empty el portapapeles está vacío
@msg collapsed must be true or false collapsed debe ser true o false
@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` no es un color como "#ff8800"
@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: como máximo {MAX_TABLE} colores
@msg command `{0}` is not available right now: {1} el comando `{0}` no está disponible ahora: {1}
@msg compound paths can only contain paths los trazados compuestos solo pueden contener trazados
@msg compressed (RLE) bitmaps can't be read yet todavía no se pueden leer mapas de bits comprimidos (RLE)
@msg damaged dañado
@msg decimals must be {_1}–{_2} (got {_3}) los decimales deben ser {_1}–{_2} (se recibió {_3})
@msg divide into 1 to {MAX_PIECES} slices divide en 1 a {MAX_PIECES} sectores
@msg effect {k} is `{_1}`, not `{id}` el efecto {k} es `{_1}`, no `{id}`
@msg empty brush stroke trazo de pincel vacío
@msg empty cutter cortador vacío
@msg empty path trazado vacío
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
@msg give corners 0–3 indica esquinas de 0 a 3
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
@msg guides are locked las guías están bloqueadas
@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió
@msg image data `{_1}` is missing faltan los datos de imagen `{_1}`
@msg image would be too large; lower the resolution la imagen sería demasiado grande; baja la resolución
@msg index must be a number or first, previous, next or last index debe ser un número o first, previous, next o last
@msg index must be a number or first, previous, next or last, not `{other}` index debe ser un número o first, previous, next o last, no `{other}`
@msg installing from a path is not available on the web; pass the module as `dataBase64` en la web no se puede instalar desde una ruta; pasa el módulo como `dataBase64`
@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) error interno en `{cmd}`: {msg} (el documento quedó como estaba; por favor, informa de este error)
@msg internal error: {msg} (please report this bug) error interno: {msg} (por favor, informa de este error)
@msg invalid PDF setting: {0} ajuste de PDF no válido: {0}
@msg invalid image data for `{0}` datos de imagen no válidos para `{0}`
@msg invalid parameters for `{cmd}`: {msg} parámetros no válidos para `{cmd}`: {msg}
@msg is not an image VectorCraft reads no es una imagen que VectorCraft pueda leer
@msg join needs open paths unir necesita trazados abiertos
@msg lang must be one of {_1} lang debe ser uno de estos: {_1}
@msg layers can't be wrap objects las capas no pueden ser objetos para ceñir
@msg missing `folder` falta `folder`
@msg missing `id` (see plugin.list) falta `id` (consulta plugin.list)
@msg missing `name` falta `name`
@msg missing `tool` falta `tool`
@msg missing index falta index
@msg multiple windows land with M11.5 las ventanas múltiples llegan con M11.5
@msg name the file ponle nombre al archivo
@msg no CSS to copy: select objects with CSS of their own no hay CSS que copiar: selecciona objetos con CSS propio
@msg no Live Paint edge near that point no hay ningún borde de pintura interactiva cerca de ese punto
@msg no Live Paint face at that point no hay ninguna cara de pintura interactiva en ese punto
@msg no PNG encoder no hay codificador PNG
@msg no active document no hay ningún documento activo
@msg no active perspective plane no hay ningún plano de perspectiva activo
@msg no anchor points selected no hay puntos de ancla seleccionados
@msg no app to open files here aquí no hay ninguna aplicación para abrir archivos
@msg no artboard no hay mesa de trabajo
@msg no artboard {i} (0-based; the document has {_1}) no existe la mesa de trabajo {i} (contando desde 0; el documento tiene {_1})
@msg no artboard {i} (0-based; the document has {count}) no existe la mesa de trabajo {i} (contando desde 0; el documento tiene {count})
@msg no artboards named no se indicaron mesas de trabajo
@msg no artwork no hay ilustración
@msg no blend options no hay opciones de fusión
@msg no bounds sin límites
@msg no brush named `{name}` no hay ningún pincel llamado `{name}`
@msg no catalog no hay catálogo
@msg no changes since the last save no hay cambios desde la última vez que se guardó
@msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad
@msg no colour group `{g}` no existe el grupo de colores `{g}`
@msg no cross-reference table no hay tabla de referencias cruzadas
@msg no current layer no hay capa actual
@msg no data no hay datos
@msg no dialog open no hay ningún cuadro de diálogo abierto
@msg no document no hay documento
@msg no document open no hay ningún documento abierto
@msg no effect applied yet todavía no se aplicó ningún efecto
@msg no effect at index {k} no hay ningún efecto en el índice {k}
@msg no end record no hay registro final
@msg no file manager here aquí no hay administrador de archivos
@msg no file reader no hay lector de archivos
@msg no file system here aquí no hay sistema de archivos
@msg no file writer no hay escritor de archivos
@msg no files to place no hay archivos para colocar
@msg no format to export to no hay formato al que exportar
@msg no free recovery area no hay ningún área de recuperación libre
@msg no gradient stop or freeform point is selected no hay ninguna parada de degradado ni punto de forma libre seleccionado
@msg no graphic style `{name}` no existe el estilo gráfico `{name}`
@msg no graphic style `{n}` no existe el estilo gráfico `{n}`
@msg no interaction in progress no hay ninguna interacción en curso
@msg no key object no hay objeto clave
@msg no library `{key}` (see {list}) no existe la biblioteca `{key}` (consulta {list})
@msg no native format no hay formato nativo
@msg no pages no hay páginas
@msg no pattern `{name}` no existe el motivo `{name}`
@msg no perspective grid preset named `{name}` no hay ningún preajuste de cuadrícula de perspectiva llamado `{name}`
@msg no plug-in {0} is installed no hay ningún plugin {0} instalado
@msg no plug-in {id} is installed no hay ningún plugin {id} instalado
@msg no point {i} (the gradient has {_1}) no existe el punto {i} (el degradado tiene {_1})
@msg no preset editor no hay editor de preajustes
@msg no previous transform no hay transformación anterior
@msg no print preset named `{base}` no hay ningún preajuste de impresión llamado `{base}`
@msg no print preset named `{name}` no hay ningún preajuste de impresión llamado `{name}`
@msg no print service no hay servicio de impresión
@msg no revert command no hay comando para volver a la versión guardada
@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) no hay ningún preajuste de PDF guardado llamado `{name}` (los preajustes integrados son de solo lectura: crea uno nuevo a partir de ellos)
@msg no saved presets no hay preajustes guardados
@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) no hay ningún preajuste de impresión guardado llamado `{name}` ({DEFAULT_PRESET} está protegido: crea uno nuevo a partir de él)
@msg no saved profiles no hay perfiles guardados
@msg no saved selections no hay selecciones guardadas
@msg no saved themes no hay temas guardados
@msg no segment {segment} on line {line} no existe el segmento {segment} en la línea {line}
@msg no selected slice in the image: select slices, or save all of them no hay ningún sector seleccionado en la imagen: selecciona sectores o guárdalos todos
@msg no selection bounds la selección no tiene límites
@msg no slice selected no hay ningún sector seleccionado
@msg no such action no existe esa acción
@msg no such anchor no existe ese punto de ancla
@msg no such artboard no existe esa mesa de trabajo
@msg no such document no existe ese documento
@msg no such layer or object no existe esa capa u objeto
@msg no such object {0} no existe el objeto {0}
@msg no such perspective grid preset no existe ese preajuste de cuadrícula de perspectiva
@msg no such recent file no existe ese archivo reciente
@msg no such recent font no existe esa fuente reciente
@msg no such saved selection no existe esa selección guardada
@msg no such segment no existe ese segmento
@msg no such subpath no existe ese subtrazado
@msg no such user library no existe esa biblioteca de usuario
@msg no such view no existe esa vista
@msg no such width point no existe ese punto de anchura
@msg no swatch `{name}` no existe la muestra `{name}`
@msg no swatch library `{l}` (see swatch.library.list) no existe la biblioteca de muestras `{l}` (consulta swatch.library.list)
@msg no symbol instances selected no hay instancias de símbolo seleccionadas
@msg no target object has an opacity mask: select one or give `id` ningún objeto de destino tiene máscara de opacidad: selecciona uno o indica `id`
@msg no threaded text selected no hay texto enlazado seleccionado
@msg no user workspace `{from}` no existe el espacio de trabajo de usuario `{from}`
@msg node {_1} is not text el nodo {_1} no es texto
@msg not a DXF drawing no es un dibujo DXF
@msg not a VectorCraft file: {0} no es un archivo de VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) no es un conjunto de atajos de VectorCraft (falta `overrides`)
@msg not a graphic style library (format `{_1}`) no es una biblioteca de estilos gráficos (formato `{_1}`)
@msg not a stored central record no es un registro central almacenado
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
@msg not an EMF or WMF file no es un archivo EMF ni WMF
@msg not found no se encontró
@msg not in pattern editing mode no está en el modo de edición de motivos
@msg nothing encoded no se codificó nada
@msg nothing selected no hay nada seleccionado
@msg nothing to align no hay nada que alinear
@msg nothing to distort no hay nada que distorsionar
@msg nothing to export: the document has no visible art (or turn on Use Artboards) no hay nada que exportar: el documento no tiene ilustración visible (o activa Usar mesas de trabajo)
@msg nothing to preview no hay nada que previsualizar
@msg nothing to print no hay nada que imprimir
@msg nothing to redo no hay nada que rehacer
@msg nothing to save: the document has no visible art no hay nada que guardar: el documento no tiene ilustración visible
@msg nothing to undo no hay nada que deshacer
@msg nothing to warp no hay nada que deformar
@msg nothing to wrap no hay nada que envolver
@msg object {0} cannot have children el objeto {0} no puede tener hijos
@msg object {id} is not a path el objeto {id} no es un trazado
@msg page size {w} × {h} tamaño de página {w} × {h}
@msg page {0} does not exist (the PDF has {1}) la página {0} no existe (el PDF tiene {1})
@msg paste unavailable pegar no está disponible
@msg path is already closed el trazado ya está cerrado
@msg paths contain invalid coordinates los trazados contienen coordenadas no válidas
@msg paths: give at least one file paths: indica al menos un archivo
@msg pattern missing falta el motivo
@msg percent must be above 0 and at most 6400, not {p} el porcentaje debe ser mayor que 0 y como máximo 6400, no {p}
@msg pick a colour, gradient or pattern to save as a swatch elige un color, degradado o motivo para guardarlo como muestra
@msg plane must be left, right or ground plane debe ser left, right o ground
@msg plug-in ABI: {0} ABI del plugin: {0}
@msg plug-in exceeded its {0} el plugin superó su {0}
@msg plug-in failed: {0} el plugin falló: {0}
@msg plug-in manifest: {0} manifiesto del plugin: {0}
@msg plug-in output: {0} salida del plugin: {0}
@msg plug-in parameters: {0} parámetros del plugin: {0}
@msg plug-in trapped: {0} el plugin se detuvo con un error: {0}
@msg print settings are not an object los ajustes de impresión no son un objeto
@msg printing isn't available here aquí no se puede imprimir
@msg range `{s}` is empty el rango `{s}` está vacío
@msg range `{s}`: `{part}` names no number rango `{s}`: `{part}` no indica ningún número
@msg range `{s}`: `{part}` runs backwards rango `{s}`: `{part}` va hacia atrás
@msg range `{s}`: `{t}` is not a number from 1 to {count} rango `{s}`: `{t}` no es un número de 1 a {count}
@msg recovery copy `{file}`: {e} copia de recuperación `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi las resoluciones deben estar entre 1 y 2400 ppi
@msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango)
@msg scale must be a number from {_1} to {_2}, not {_3} la escala debe ser un número de {_1} a {_2}, no {_3}
@msg scale must be a number from {_1} to {_2}, not {s} la escala debe ser un número de {_1} a {_2}, no {s}
@msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica
@msg select a Live Paint group selecciona un grupo de pintura interactiva
@msg select a blend selecciona una fusión
@msg select a clip group selecciona un grupo de recorte
@msg select a gradient mesh selecciona una malla de degradado
@msg select a linked image: an embedded one has no file selecciona una imagen vinculada: una incrustada no tiene archivo
@msg select a repeat selecciona una repetición
@msg select a stroke with a variable width selecciona un trazo con anchura variable
@msg select a symbol instance selecciona una instancia de símbolo
@msg select an Image Trace object selecciona un objeto de calco de imagen
@msg select an envelope selecciona una envolvente
@msg select an image selecciona una imagen
@msg select an object in perspective selecciona un objeto en perspectiva
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
@msg select at least two objects selecciona al menos dos objetos
@msg select objects, not layers selecciona objetos, no capas
@msg select one live rectangle, or give its `id` selecciona un rectángulo interactivo o indica su `id`
@msg select paths to join selecciona los trazados que quieres unir
@msg select the art to repeat selecciona la ilustración que quieres repetir
@msg select the artwork to warp selecciona la ilustración que quieres deformar
@msg select the objects to transform in perspective selecciona los objetos que quieres transformar en perspectiva
@msg select two or more graphic styles to merge selecciona dos o más estilos gráficos para combinarlos
@msg select type in perspective selecciona texto en perspectiva
@msg selection has no bounds la selección no tiene límites
@msg shortcut for `{k}` must be a string el atajo de `{k}` debe ser un texto
@msg stop {i} needs `offset` la parada {i} necesita `offset`
@msg stop {i} needs a valid `color` la parada {i} necesita un `color` válido
@msg stop {i}: `{k}` must be a number parada {i}: `{k}` debe ser un número
@msg the EMF header is damaged el encabezado EMF está dañado
@msg the PDF has no pages el PDF no tiene páginas
@msg the PDF is no longer loaded el PDF ya no está cargado
@msg the PDF is password-protected: give its password el PDF está protegido con contraseña: indica su contraseña
@msg the PDF password is wrong la contraseña del PDF es incorrecta
@msg the Save PDF dialog didn't open el cuadro de diálogo Guardar PDF no se abrió
@msg the WMF header is damaged el encabezado WMF está dañado
@msg the art could not be sampled no se pudo muestrear la ilustración
@msg the art has no bounds la ilustración no tiene límites
@msg the art is too light for any dots la ilustración es demasiado clara para generar puntos
@msg the artboard to fit must be a positive size up to {_1} pt la mesa de trabajo a la que ajustar debe tener un tamaño positivo de hasta {_1} pt
@msg the background worker stopped el proceso en segundo plano se detuvo
@msg the compressed file unpacks to more than {_1} MB el archivo comprimido ocupa más de {_1} MB al descomprimirse
@msg the document has never been saved el documento nunca se guardó
@msg the document has no artboard el documento no tiene mesa de trabajo
@msg the document has no artboards el documento no tiene mesas de trabajo
@msg the document has no patterns el documento no tiene motivos
@msg the document has no slices el documento no tiene sectores
@msg the document has no symbols el documento no tiene símbolos
@msg the drawing has no layout `{_1}` (it has {_2}) el dibujo no tiene la presentación `{_1}` (tiene {_2})
@msg the drawing is no longer loaded el dibujo ya no está cargado
@msg the drawing is too large to open el dibujo es demasiado grande para abrirlo
@msg the export region is empty la región de exportación está vacía
@msg the export region is not finite la región de exportación no es finita
@msg the export returned unreadable data la exportación devolvió datos ilegibles
@msg the export wrote no file la exportación no escribió ningún archivo
@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG el archivo solo contiene una página provisional, no su ilustración (se guardó sin compatibilidad con PDF): vuelve a guardarlo con la compatibilidad con PDF activada, o como PDF o SVG
@msg the file's preview header points outside the file el encabezado de previsualización del archivo apunta fuera del archivo
@msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño
@msg the layer has no objects to clip by la capa no tiene objetos con los que recortar
@msg the layer or group is locked la capa o el grupo está bloqueado
@msg the layer to flatten into is hidden la capa en la que acoplar está oculta
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
@msg the object has no geometry el objeto no tiene geometría
@msg the object has no opacity mask el objeto no tiene máscara de opacidad
@msg the object has no stroke el objeto no tiene trazo
@msg the object is beyond the plane's horizon el objeto está más allá del horizonte del plano
@msg the object isn't in perspective el objeto no está en perspectiva
@msg the object isn't on a perspective plane el objeto no está en un plano de perspectiva
@msg the object would cross the horizon of the perspective plane el objeto cruzaría el horizonte del plano de perspectiva
@msg the objects would leave the grid los objetos saldrían de la cuadrícula
@msg the package came back damaged el paquete llegó dañado
@msg the pointer is beyond the plane's horizon el puntero está más allá del horizonte del plano
@msg the preview image is empty la imagen de previsualización está vacía
@msg the print job has no data el trabajo de impresión no tiene datos
@msg the scale is not a usable number la escala no es un número utilizable
@msg the selected stroke's profile is already listed as {_1} el perfil del trazo seleccionado ya figura como {_1}
@msg the selection has no URL (or they differ) la selección no tiene URL (o son distintas)
@msg the selection isn't in perspective la selección no está en perspectiva
@msg the stroke has no weight el trazo no tiene grosor
@msg the thumbnail image is malformed la miniatura está mal formada
@msg the top object must be a path, compound path or text object to use as a clipping mask el objeto superior debe ser un trazado, un trazado compuesto o un objeto de texto para usarlo como máscara de recorte
@msg the {tool} tool has no options la herramienta {tool} no tiene opciones
@msg there are no objects to release no hay objetos que soltar
@msg there are no printer settings to open here aquí no hay ajustes de impresora que abrir
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({_1}): guárdalo como PDF o SVG en la aplicación que lo creó
@msg this format holds one artboard: export several as PDF or with document.exportForScreens este formato admite una sola mesa de trabajo: exporta varias como PDF o con document.exportForScreens
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF ASCII y abre ese archivo
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG este es un archivo PostScript (un .ai guardado en un formato antiguo o sin compatibilidad con PDF, o un archivo EPS), que todavía no se puede abrir: guárdalo como PDF, .ai compatible con PDF o SVG
@msg this is not a PostScript file este no es un archivo PostScript
@msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift)
@msg truncated truncado
@msg unknown command `{0}` comando desconocido `{0}`
@msg unknown effect `{id}` efecto desconocido `{id}`
@msg unknown format formato desconocido
@msg unknown format `{f}` (Save writes {_1}) formato desconocido `{f}` (Guardar escribe {_1})
@msg unknown freeform mode `{m}` (points, lines) modo de forma libre desconocido `{m}` (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, freeform) tipo de degradado desconocido `{k}` (linear, radial, freeform)
@msg unknown highlight `{h}` resaltado desconocido `{h}`
@msg unknown option `{path}.{k}` opción desconocida `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard)
@msg unknown panel `{raw}` panel desconocido `{raw}`
@msg unknown preference `{key}` preferencia desconocida `{key}`
@msg unknown preset (one of {_1}) preajuste desconocido (uno de estos: {_1})
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preajuste desconocido `{name}` (high, medium, low o uno guardado: consulta flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) preajuste desconocido `{name}` (consulta flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) preajuste desconocido `{name}` (consulta webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) lado desconocido `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` herramienta desconocida `{tool}`
@msg unknown tool `{t}` herramienta desconocida `{t}`
@msg unknown tool or command `{key}` herramienta o comando desconocido `{key}`
@msg unknown workspace (one of {_1}) espacio de trabajo desconocido (uno de estos: {_1})
@msg unsupported: {0} no compatible: {0}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files los archivos de la versión {0} no se pueden comprimir: solo la versión {COMPRESSED_SINCE} y posteriores abren archivos comprimidos
@msg width and height are at least 1 pixel el ancho y el alto son de al menos 1 píxel
@msg workspace name can't be empty el nombre del espacio de trabajo no puede estar vacío
@msg {C}: no effect at index {from} {C}: no hay ningún efecto en el índice {from}
@msg {_1} failed: {e} {_1} falló: {e}
@msg {_1} files can be opened but not written (see document.formats) los archivos {_1} se pueden abrir pero no escribir (consulta document.formats)
@msg {_1} font families available {_1} familias de fuentes disponibles
@msg {_1} is a live effect (Effect › Plug-ins) {_1} es un efecto interactivo (Efecto › Plugins)
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
@msg {command} didn't create an object {command} no creó ningún objeto
@msg {label} produced an empty result {label} produjo un resultado vacío
@msg {label}: lost track of a shape's style {label}: se perdió el estilo de una forma
@msg {label}: select paths {label}: selecciona trazados
@msg {label}: select paths, compound paths, groups or text {label}: selecciona trazados, trazados compuestos, grupos o texto
@msg {label}… {label}…
@msg {name}: CRC mismatch {name}: el CRC no coincide
@msg {name}: no local header {name}: no hay encabezado local
@msg {name}: no such recovery entry {name}: no existe esa entrada de recuperación
@msg {path} can't be found: relink it first no se encuentra {path}: vuelve a vincularlo primero
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: aquí no hay sistema de archivos (pasa dataBase64 para abrir; omite la ruta para obtener dataBase64)
@msg {path}: {e} {path}: {e}
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} No se pudo colocar {name}: {e}
@msg Couldn't paste from the clipboard: {e} No se pudo pegar desde el portapapeles: {e}
@msg Saved {path} with {_1} note(s): {first} Se guardó {path} con {_1} nota(s): {first}
@msg Exported {what} with {_1} note(s): {first} Se exportó {what} con {_1} nota(s): {first}
@msg Exported {what} Se exportó {what}
@msg Saved {n} files beside {path} Se guardaron {n} archivos junto a {path}
@msg Saving Guardando
@msg Saving {_1} Guardando {_1}
@msg Exporting {_1} Exportando {_1}
@msg Saving recovery data for {_1} Guardando datos de recuperación de {_1}
@msg Discarded {_1} recovered document(s) Se descartaron {_1} documento(s) recuperado(s)
@msg Restored {_1} recovered document(s): save them to keep them Se restauraron {_1} documento(s) recuperado(s): guárdalos para conservarlos
@msg Embedded {n} image(s); {missing} missing: relink them first Se incrustaron {n} imagen(es); faltan {missing}: vuelve a vincularlas primero
@msg Embedded {n} image(s) Se incrustaron {n} imagen(es)
@msg Relinked {_1} image(s) Se volvieron a vincular {_1} imagen(es)
@msg Relinked {n} image(s) Se volvieron a vincular {n} imagen(es)
@msg Relinked {n} image(s); not in that folder: {_1} Se volvieron a vincular {n} imagen(es); no están en esa carpeta: {_1}
@msg Updated {_1} image(s) Se actualizaron {_1} imagen(es)
@msg Unembedded to {_1} Se desincrustó en {_1}
@msg Reset {_1} Se restableció {_1}
@msg Sent to {_1} Se envió a {_1}
@msg {_1} files {_1} archivos
@msg The graphics were lost and have been restarted Se perdieron los gráficos y se reiniciaron
@msg Loading VectorCraft… Cargando VectorCraft…
@msg VectorCraft failed to start: {e} VectorCraft no pudo iniciarse: {e}
@msg A browser with WebGPU or WebGL2 is required. Se necesita un navegador con WebGPU o WebGL2.
@msg The graphics were lost and couldn't be restarted. Se perdieron los gráficos y no se pudieron reiniciar.
@msg Your unsaved changes are kept in this browser: reload the page to get them back. Tus cambios sin guardar se conservan en este navegador: vuelve a cargar la página para recuperarlos.
@msg Reload the page to go on (changes not saved yet may be lost). Vuelve a cargar la página para continuar (los cambios que aún no se guardaron podrían perderse).
@msg floating must be true or false floating debe ser true o false
@msg tool (a tool id) is required se requiere tool (un id de herramienta)
@msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas
@msg double must be true or false double debe ser true o false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+21 -1
View File
@@ -10,12 +10,20 @@
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
About VectorCraft VectorCraft について
All Scripts すべての文字
Anchors: アンカー:
Any Kind すべての種類
Ascender アセンダ
Baseline ベースライン
Bottom-to-Bottom Leading 欧文ベースライン基準の行送り
Burasagari ぶら下がり
Force 強制
Center 中央
Character Alignment 文字揃え
Connect Selected End Points 選択した端点を連結
Convert Selected Anchor Points to Corner 選択したアンカーポイントをコーナーポイントに変換
Convert Selected Anchor Points to Smooth 選択したアンカーポイントをスムーズポイントに変換
Convert: 変換:
Cut Path パスをカット
Decorative 装飾
Descender ディセンダ
Em Box Bottom/Left 仮想ボディの下 / 左
@@ -158,6 +166,7 @@
Expand… 分割・拡張…
Expand Appearance アピアランスを分割
Crop Image 画像の切り抜き
Mask マスク
Rasterize… ラスタライズ…
Create Gradient Mesh… グラデーションメッシュを作成…
Create Object Mosaic… モザイクオブジェクトを作成…
@@ -222,6 +231,8 @@
Merge 結合
Gap Options… 間隔オプション…
Image Trace 画像トレース
Image Tracing 画像トレースオブジェクト
Tracing Presets トレースプリセット
Make… 作成…
Make and Expand… 作成して拡張…
Text Wrap テキストの回り込み
@@ -503,7 +514,7 @@
Tools ツール
Toolbars ツールバー
Advanced 詳細
Single / Double Column 1 列/2 列
Double Column 2 列
Actions アクション
Asset Export アセットの書き出し
Attributes 属性
@@ -1116,6 +1127,8 @@
Drawing Mode 描画モード
Duplicate 複製
Duplicate Artboard アートボードを複製
Copy Artboard アートボードをコピー
Cut Artboard アートボードをカット
Duplicate Artboards アートボードを複製
Duplicate Brush ブラシを複製
Duplicate Character Style 文字スタイルを複製
@@ -2332,6 +2345,8 @@
Stop editing the opacity mask 不透明マスクの編集を終了
Strikethrough 打ち消し線
Stroke Options 線オプション
Character: 文字:
Character options 文字オプション
Stroke options 線オプション
Stroke: 線:
Style スタイル
@@ -2415,6 +2430,7 @@
Total Width: 合計幅:
Touch Type Tool 文字タッチツール
Trace トレース
Trace the image with the Default preset 「デフォルト」プリセットで画像をトレース
Tracking トラッキング
Transform Both 両方を変形
Transform Each 個別に変形
@@ -3018,3 +3034,7 @@
axis Horizontal 水平方向
axis Vertical 垂直方向
axis Both 両方
Drag to move 移動するにはドラッグ
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
Put back in the toolbar ツールバーに戻す
Float Tool Group ツールグループをフローティング
Can't render this file because it contains an unexpected character in line 2 and column 73.
+52 -1
View File
@@ -19,6 +19,10 @@
//! translation survives rewording of the English text and can differ per command.
//! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`];
//! translators may reorder placeholders freely.
//! - [`message`] / [`msg`]: a status or error message, translated only where it is shown. The
//! message itself stays English (the control channel, MCP and tests read it); `@msg` catalog
//! rows hold whole messages or templates such as `Couldn't open {name}: {e}`, and the values in
//! the placeholders are translated in turn (the reason after `: {e}` is often a message too).
mod catalog;
@@ -212,16 +216,28 @@ pub fn detect_system_lang_in_background() {
}
}
/// The system language, worked out once.
static SYSTEM: OnceLock<Lang> = OnceLock::new();
/// The system language (cached). English when it can't be determined.
pub fn system_lang() -> Lang {
// Tests drive the UI by its English labels whatever the developer's locale is.
if cfg!(test) {
return Lang::EN;
}
static SYSTEM: OnceLock<Lang> = OnceLock::new();
*SYSTEM.get_or_init(detect_system_lang)
}
/// Tell the UI the system's preferred languages, most preferred first, where it can't find them
/// itself: the web shell passes the browser's (`navigator.languages`). The first supported one is
/// the system language (English if none is); call it before the first frame. Has no effect once
/// the system language is known.
pub fn set_system_locales<S: AsRef<str>>(tags: &[S]) {
let lang = tags.iter().find_map(|t| lang_from_tag(t.as_ref())).unwrap_or(Lang::EN);
// Already set means already detected (or reported): the first answer stands.
let _ = SYSTEM.set(lang);
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_system_lang() -> Lang {
// `VECTORCRAFT_LOCALE` overrides everything (also handy for screenshots); then the POSIX
@@ -277,6 +293,7 @@ fn registry_locale(text: &str) -> Option<String> {
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
}
/// The browser's languages arrive through [`set_system_locales`]; without them, English.
#[cfg(target_arch = "wasm32")]
fn detect_system_lang() -> Lang {
Lang::EN
@@ -351,6 +368,40 @@ pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str {
if built_in { tr(lang, s) } else { s }
}
/// How deep [`message`] translates placeholder values that are messages themselves.
const MESSAGE_DEPTH: usize = 3;
/// Translate a status or error message for display (see the module docs). Unknown messages, and
/// parts of them (file names, numbers, a reason with no entry), stay as they are.
pub fn message(lang: Lang, s: &str) -> String {
message_at(lang, s, MESSAGE_DEPTH)
}
fn message_at(lang: Lang, s: &str, depth: usize) -> String {
let Some((template, caps)) = lang.catalog().message(s) else { return s.to_string() };
let values: Vec<(&str, String)> =
caps.into_iter().map(|(k, v)| (k, if depth > 1 { message_at(lang, v, depth - 1) } else { v.to_string() })).collect();
let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
fmt(template, &args)
}
/// [`message`] in the current language, remembering the last answer: the status bar asks for the
/// same message every frame.
pub fn msg(s: &str) -> String {
static LAST: std::sync::Mutex<Option<(&'static str, String, String)>> = std::sync::Mutex::new(None);
let lang = current();
let mut last = LAST.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((code, src, out)) = last.as_ref()
&& *code == lang.code()
&& src == s
{
return out.clone();
}
let out = message(lang, s);
*last = Some((lang.code(), s.to_string(), out.clone()));
out
}
/// [`trn`] in the current language.
pub fn tn(n: u64, one: &str, other: &str) -> String {
trn(current(), n, one, other)
+10 -1
View File
@@ -508,6 +508,7 @@
Created: Criado:
Crop Recortar
Crop Image Recortar Imagem
Mask Máscara
Crop Marks Marcas de Corte
Crop To Recortar Para
Crystallize Tool Ferramenta Cristalizar
@@ -666,6 +667,8 @@
Duotone Gradients Gradientes Duotônicos
Duplicate Duplicar
Duplicate Artboard Duplicar Mesa de Trabalho
Copy Artboard Copiar Mesa de Trabalho
Cut Artboard Recortar Mesa de Trabalho
Duplicate Artboards Duplicar Mesas de Trabalho
Duplicate Brush Duplicar Pincel
Duplicate Character Style Duplicar Estilo de Caractere
@@ -2151,7 +2154,7 @@
Simplify… Simplificar…
Simulate Colored Paper Simular Papel Colorido
Simulate Paper Simular Papel
Single / Double Column Coluna Única / Dupla
Double Column Coluna Dupla
Single Left Quotation Mark Aspa Esquerda Única
Single Quotes: Aspas Simples:
Single Right Quotation Mark Aspa Direita Única
@@ -2238,6 +2241,8 @@
Stroke Color Cor do Traçado
Stroke Options Opções de Traçado
Stroke Weight Peso do Traçado
Character: Caractere:
Character options Opções de caractere
Stroke options Opções de traçado
Stroke: Traçado:
Style Estilo
@@ -2621,3 +2626,7 @@
Version Versão
% of all commits % de todos os commits
Lines +/− Linhas +/−
Drag to move Arraste para mover
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
Put back in the toolbar Devolver à barra de ferramentas
Float Tool Group Flutuar Grupo de Ferramentas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+147
View File
@@ -601,3 +601,150 @@ fn a_language_saved_by_an_older_version_carries_over() {
crate::prefs_dialog::restore(&mut app);
assert_eq!(app.session.prefs.interface_language, "cs");
}
#[test]
fn messages_translate_by_template_and_their_parts() {
let c = Catalog::parse(concat!(
"@msg\tnothing selected\tnada seleccionado\n",
"@msg\t{path}: {e}\t{path}: {e}\n",
"@msg\tCouldn't open {name}: {e}\tNo se pudo abrir {name}: {e}\n",
"@msg\tcommand `{0}` is not available right now: {1}\tel comando `{0}` no está disponible ahora: {1}\n",
"@msg\t{a}{b}\tnever\n",
"@msg\t{n} of {total}\t{total} contiene {n}\n",
));
assert_eq!(c.message("nothing selected").map(|m| m.0), Some("nada seleccionado"));
// The template with the most literal text wins over the generic `{path}: {e}`.
let (tr, caps) = c.message("Couldn't open a: b.svg: damaged").unwrap();
assert_eq!(tr, "No se pudo abrir {name}: {e}");
assert_eq!(caps, [("name", "a"), ("e", "b.svg: damaged")], "a placeholder takes the shortest text that lets the rest match");
assert_eq!(c.message("x of y").unwrap().1, [("n", "x"), ("total", "y")]);
assert!(c.message("of y").is_none(), "a placeholder is never empty");
assert!(c.message("something else").is_none());
assert!(c.message(&"x".repeat(5000)).is_none());
let es = Lang::from_code("es").unwrap();
assert_eq!(message(es, "no such message"), "no such message");
// A reason inside a message is translated too, and parts without entries stay as they are.
let nested = "command `object.group` is not available right now: nothing selected";
let shown = message(es, nested);
assert!(shown.contains("`object.group`") && !shown.contains("nothing selected") && !shown.contains("not available"), "{shown}");
assert_eq!(message(Lang::EN, nested), nested);
}
/// Each language that translates status and error messages ([`COMPLETE_MESSAGES`]) has an `@msg`
/// row for every message in the sources that reach the status bar, so a new one can't ship
/// untranslated by accident (`VECTORCRAFT_I18N_DUMP_MESSAGES=<file>` lists them).
#[test]
fn complete_languages_translate_every_message() {
let messages = message_literals();
assert!(messages.len() > 300, "scan found only {} messages", messages.len());
if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") {
std::fs::write(path, messages.iter().map(|m| format!("{m}\n")).collect::<String>()).expect("write dump");
}
for code in COMPLETE_MESSAGES {
let lang = Lang::from_code(code).expect("registered");
let (entries, _) = parse_entries(lang.0.source);
let rows: std::collections::HashSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect();
let missing: Vec<_> = messages.iter().filter(|m| !rows.contains(m.as_str())).collect();
assert!(missing.is_empty(), "{code}: {} untranslated messages: {missing:#?}", missing.len());
}
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["es"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad"];
/// Where a message literal starts: a status, an error value, a `thiserror` message.
const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Other(", "Err(", "ok_or(", "ok_or_else(|| ", "#[error("];
/// Messages that are signals, not text for people.
const NOT_MESSAGES: &[&str] = &["quit", "cancelled"];
/// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog
/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`).
pub fn message_literals() -> std::collections::BTreeSet<String> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut out = std::collections::BTreeSet::new();
let mut stack: Vec<_> = MESSAGE_CRATES.iter().map(|c| crates.join(c).join("src")).collect();
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if name != "tests" {
stack.push(path);
}
} else if name.ends_with(".rs") && !name.starts_with("tests") {
let text = std::fs::read_to_string(&path).unwrap_or_default();
let code = without_test_module(&text);
for marker in MESSAGE_MARKERS {
for (at, _) in code.match_indices(marker) {
let rest = code[at + marker.len()..].trim_start();
let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start);
let Some(lit) = rest.strip_prefix('"').and_then(string_literal) else { continue };
let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase));
if has_word && !NOT_MESSAGES.contains(&lit.as_str()) {
out.insert(as_template(&lit));
}
}
}
}
}
}
out
}
/// Source up to its inline test module (`#[cfg(test)] mod tests {`); declarations of test files
/// (`#[cfg(test)] mod tests_x;`) don't end it.
fn without_test_module(text: &str) -> &str {
for (at, m) in text.match_indices("#[cfg(test)]") {
let after = text[at + m.len()..].trim_start();
if let Some(rest) = after.strip_prefix("mod ")
&& rest.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_').trim_start().starts_with('{')
{
return &text[..at];
}
}
text
}
/// The text of a string literal whose opening quote was just read, escapes resolved.
fn string_literal(s: &str) -> Option<String> {
let mut out = String::new();
let mut it = s.chars();
loop {
match it.next()? {
'"' => return Some(out),
'\\' => match it.next()? {
'n' => out.push('\n'),
't' => out.push('\t'),
c => out.push(c),
},
c => out.push(c),
}
}
}
/// A format string as a catalog template (see [`message_literals`]).
fn as_template(s: &str) -> String {
let mut out = String::new();
let mut unnamed = 0;
let mut rest = s;
while let Some(a) = rest.find('{') {
out.push_str(&rest[..a]);
let after = &rest[a + 1..];
let Some(b) = after.find('}') else { break };
let name = after[..b].split(':').next().unwrap_or("");
if name.is_empty() {
unnamed += 1;
out.push_str(&format!("{{_{unnamed}}}"));
} else {
out.push_str(&format!("{{{name}}}"));
}
rest = &after[b + 1..];
}
out.push_str(rest);
out
}
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more